diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-09-20 13:18:24 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-09-20 13:18:24 +0000 |
commit | 0653e08efd039a5905f3fa4f6e9cef9f5d2f799c (patch) | |
tree | 4dcc884cf6d81db44adae4aa99f8ec1233a41f55 /spec/frontend | |
parent | 744144d28e3e7fddc117924fef88de5d9674fe4c (diff) | |
download | gitlab-ce-0653e08efd039a5905f3fa4f6e9cef9f5d2f799c.tar.gz |
Add latest changes from gitlab-org/gitlab@14-3-stable-eev14.3.0-rc42
Diffstat (limited to 'spec/frontend')
271 files changed, 8337 insertions, 10276 deletions
diff --git a/spec/frontend/__helpers__/emoji.js b/spec/frontend/__helpers__/emoji.js index 9f9134f6f63..a64135601ae 100644 --- a/spec/frontend/__helpers__/emoji.js +++ b/spec/frontend/__helpers__/emoji.js @@ -49,6 +49,11 @@ export const emojiFixtureMap = { unicodeVersion: '5.1', description: 'white medium star', }, + xss: { + moji: '<img src=x onerror=prompt(1)>', + unicodeVersion: '5.1', + description: 'xss', + }, }; export const mockEmojiData = Object.keys(emojiFixtureMap).reduce((acc, k) => { diff --git a/spec/frontend/__helpers__/local_storage_helper.js b/spec/frontend/__helpers__/local_storage_helper.js index 21749fd8070..cf75b0b53fe 100644 --- a/spec/frontend/__helpers__/local_storage_helper.js +++ b/spec/frontend/__helpers__/local_storage_helper.js @@ -2,9 +2,7 @@ * Manage the instance of a custom `window.localStorage` * * This only encapsulates the setup / teardown logic so that it can easily be - * reused with different implementations (i.e. a spy or a [fake][1]) - * - * [1]: https://stackoverflow.com/a/41434763/1708147 + * reused with different implementations (i.e. a spy or a fake) * * @param {() => any} fn Function that returns the object to use for localStorage */ diff --git a/spec/frontend/__helpers__/mock_window_location_helper.js b/spec/frontend/__helpers__/mock_window_location_helper.js index 3755778e5c1..14082857053 100644 --- a/spec/frontend/__helpers__/mock_window_location_helper.js +++ b/spec/frontend/__helpers__/mock_window_location_helper.js @@ -2,9 +2,7 @@ * Manage the instance of a custom `window.location` * * This only encapsulates the setup / teardown logic so that it can easily be - * reused with different implementations (i.e. a spy or a [fake][1]) - * - * [1]: https://stackoverflow.com/a/41434763/1708147 + * reused with different implementations (i.e. a spy or a fake) * * @param {() => any} fn Function that returns the object to use for window.location */ diff --git a/spec/frontend/__helpers__/test_apollo_link.js b/spec/frontend/__helpers__/test_apollo_link.js new file mode 100644 index 00000000000..dde3a4e99bb --- /dev/null +++ b/spec/frontend/__helpers__/test_apollo_link.js @@ -0,0 +1,46 @@ +import { InMemoryCache } from 'apollo-cache-inmemory'; +import { ApolloClient } from 'apollo-client'; +import { ApolloLink } from 'apollo-link'; +import gql from 'graphql-tag'; + +const FOO_QUERY = gql` + query { + foo + } +`; + +/** + * This function returns a promise that resolves to the final operation after + * running an ApolloClient query with the given ApolloLink + * + * @typedef {Object} TestApolloLinkOptions + * @property {Object} context the default context object sent along the ApolloLink chain + * + * @param {ApolloLink} subjectLink the ApolloLink which is under test + * @param {TestApolloLinkOptions} options contains options to send a long with the query + * + * @returns Promise resolving to the resulting operation after running the subjectLink + */ +export const testApolloLink = (subjectLink, options = {}) => + new Promise((resolve) => { + const { context = {} } = options; + + // Use the terminating link to capture the final operation and resolve with this. + const terminatingLink = new ApolloLink((operation) => { + resolve(operation); + + return null; + }); + + const client = new ApolloClient({ + link: ApolloLink.from([subjectLink, terminatingLink]), + // cache is a required option + cache: new InMemoryCache(), + }); + + // Trigger a query so the ApolloLink chain will be executed. + client.query({ + context, + query: FOO_QUERY, + }); + }); diff --git a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap index 3a374084dbc..ddb188edb10 100644 --- a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap +++ b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap @@ -51,10 +51,14 @@ exports[`Alert integration settings form default state should match the default <gl-dropdown-stub block="true" category="primary" + clearalltext="Clear all" data-qa-selector="incident_templates_dropdown" headertext="" hideheaderborder="true" + highlighteditemstitle="Selected" + highlighteditemstitleclass="gl-px-5" id="alert-integration-settings-issue-template" + showhighlighteditemstitle="true" size="medium" text="selecte_tmpl" variant="default" diff --git a/spec/frontend/api/projects_api_spec.js b/spec/frontend/api/projects_api_spec.js new file mode 100644 index 00000000000..8f40b557e1f --- /dev/null +++ b/spec/frontend/api/projects_api_spec.js @@ -0,0 +1,62 @@ +import MockAdapter from 'axios-mock-adapter'; +import * as projectsApi from '~/api/projects_api'; +import axios from '~/lib/utils/axios_utils'; + +describe('~/api/projects_api.js', () => { + let mock; + let originalGon; + + const projectId = 1; + + beforeEach(() => { + mock = new MockAdapter(axios); + + originalGon = window.gon; + window.gon = { api_version: 'v7' }; + }); + + afterEach(() => { + mock.restore(); + window.gon = originalGon; + }); + + describe('getProjects', () => { + beforeEach(() => { + jest.spyOn(axios, 'get'); + }); + + it('retrieves projects from the correct URL and returns them in the response data', () => { + const expectedUrl = '/api/v7/projects.json'; + const expectedParams = { params: { per_page: 20, search: '', simple: true } }; + const expectedProjects = [{ name: 'project 1' }]; + const query = ''; + const options = {}; + + mock.onGet(expectedUrl).reply(200, { data: expectedProjects }); + + return projectsApi.getProjects(query, options).then(({ data }) => { + expect(axios.get).toHaveBeenCalledWith(expectedUrl, expectedParams); + expect(data.data).toEqual(expectedProjects); + }); + }); + }); + + describe('importProjectMembers', () => { + beforeEach(() => { + jest.spyOn(axios, 'post'); + }); + + it('posts to the correct URL and returns the response message', () => { + const targetId = 2; + const expectedUrl = '/api/v7/projects/1/import_project_members/2'; + const expectedMessage = 'Successfully imported'; + + mock.onPost(expectedUrl).replyOnce(200, expectedMessage); + + return projectsApi.importProjectMembers(projectId, targetId).then(({ data }) => { + expect(axios.post).toHaveBeenCalledWith(expectedUrl); + expect(data).toEqual(expectedMessage); + }); + }); + }); +}); diff --git a/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js b/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js index b77def195b6..2dcc537809f 100644 --- a/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js +++ b/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js @@ -78,7 +78,7 @@ describe('RecoveryCodes', () => { it('fires Snowplow event', () => { expect(findProceedButton().attributes()).toMatchObject({ - 'data-track-event': 'click_button', + 'data-track-action': 'click_button', 'data-track-label': '2fa_recovery_codes_proceed_button', }); }); diff --git a/spec/frontend/autosave_spec.js b/spec/frontend/autosave_spec.js index bbdf3c6f91d..c881e0f9794 100644 --- a/spec/frontend/autosave_spec.js +++ b/spec/frontend/autosave_spec.js @@ -15,28 +15,28 @@ describe('Autosave', () => { describe('class constructor', () => { beforeEach(() => { - jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(true); + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true); jest.spyOn(Autosave.prototype, 'restore').mockImplementation(() => {}); }); it('should set .isLocalStorageAvailable', () => { autosave = new Autosave(field, key); - expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + expect(AccessorUtilities.canUseLocalStorage).toHaveBeenCalled(); expect(autosave.isLocalStorageAvailable).toBe(true); }); it('should set .isLocalStorageAvailable if fallbackKey is passed', () => { autosave = new Autosave(field, key, fallbackKey); - expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + expect(AccessorUtilities.canUseLocalStorage).toHaveBeenCalled(); expect(autosave.isLocalStorageAvailable).toBe(true); }); it('should set .isLocalStorageAvailable if lockVersion is passed', () => { autosave = new Autosave(field, key, null, lockVersion); - expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + expect(AccessorUtilities.canUseLocalStorage).toHaveBeenCalled(); expect(autosave.isLocalStorageAvailable).toBe(true); }); }); diff --git a/spec/frontend/batch_comments/components/review_bar_spec.js b/spec/frontend/batch_comments/components/review_bar_spec.js new file mode 100644 index 00000000000..f50db6ab210 --- /dev/null +++ b/spec/frontend/batch_comments/components/review_bar_spec.js @@ -0,0 +1,42 @@ +import { shallowMount } from '@vue/test-utils'; +import ReviewBar from '~/batch_comments/components/review_bar.vue'; +import { REVIEW_BAR_VISIBLE_CLASS_NAME } from '~/batch_comments/constants'; +import createStore from '../create_batch_comments_store'; + +describe('Batch comments review bar component', () => { + let store; + let wrapper; + + const createComponent = (propsData = {}) => { + store = createStore(); + + wrapper = shallowMount(ReviewBar, { + store, + propsData, + }); + }; + + beforeEach(() => { + document.body.className = ''; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('it adds review-bar-visible class to body when review bar is mounted', async () => { + expect(document.body.classList.contains(REVIEW_BAR_VISIBLE_CLASS_NAME)).toBe(false); + + createComponent(); + + expect(document.body.classList.contains(REVIEW_BAR_VISIBLE_CLASS_NAME)).toBe(true); + }); + + it('it removes review-bar-visible class to body when review bar is destroyed', async () => { + createComponent(); + + wrapper.destroy(); + + expect(document.body.classList.contains(REVIEW_BAR_VISIBLE_CLASS_NAME)).toBe(false); + }); +}); diff --git a/spec/frontend/batch_comments/create_batch_comments_store.js b/spec/frontend/batch_comments/create_batch_comments_store.js new file mode 100644 index 00000000000..10dc6fe196e --- /dev/null +++ b/spec/frontend/batch_comments/create_batch_comments_store.js @@ -0,0 +1,15 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import batchCommentsModule from '~/batch_comments/stores/modules/batch_comments'; +import notesModule from '~/notes/stores/modules'; + +Vue.use(Vuex); + +export default function createDiffsStore() { + return new Vuex.Store({ + modules: { + notes: notesModule(), + batchComments: batchCommentsModule(), + }, + }); +} diff --git a/spec/frontend/blob/notebook/notebook_viever_spec.js b/spec/frontend/blob/notebook/notebook_viever_spec.js index 604104bb31f..93406db2675 100644 --- a/spec/frontend/blob/notebook/notebook_viever_spec.js +++ b/spec/frontend/blob/notebook/notebook_viever_spec.js @@ -11,6 +11,7 @@ describe('iPython notebook renderer', () => { let mock; const endpoint = 'test'; + const relativeRawPath = ''; const mockNotebook = { cells: [ { @@ -27,7 +28,7 @@ describe('iPython notebook renderer', () => { }; const mountComponent = () => { - wrapper = shallowMount(component, { propsData: { endpoint } }); + wrapper = shallowMount(component, { propsData: { endpoint, relativeRawPath } }); }; const findLoading = () => wrapper.find(GlLoadingIcon); diff --git a/spec/frontend/boards/board_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js index 7d3ecc773a6..e0446811f64 100644 --- a/spec/frontend/boards/board_card_inner_spec.js +++ b/spec/frontend/boards/board_card_inner_spec.js @@ -2,6 +2,7 @@ import { GlLabel, GlLoadingIcon, GlTooltip } from '@gitlab/ui'; import { range } from 'lodash'; import Vuex from 'vuex'; import setWindowLocation from 'helpers/set_window_location_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import BoardBlockedIcon from '~/boards/components/board_blocked_icon.vue'; import BoardCardInner from '~/boards/components/board_card_inner.vue'; @@ -44,6 +45,7 @@ describe('Board card component', () => { const findEpicBadgeProgress = () => wrapper.findByTestId('epic-progress'); const findEpicCountablesTotalWeight = () => wrapper.findByTestId('epic-countables-total-weight'); const findEpicProgressTooltip = () => wrapper.findByTestId('epic-progress-tooltip-content'); + const findHiddenIssueIcon = () => wrapper.findByTestId('hidden-icon'); const createStore = ({ isEpicBoard = false, isProjectBoard = false } = {}) => { store = new Vuex.Store({ @@ -72,6 +74,9 @@ describe('Board card component', () => { GlLabel: true, GlLoadingIcon: true, }, + directives: { + GlTooltip: createMockDirective(), + }, mocks: { $apollo: { queries: { @@ -122,6 +127,10 @@ describe('Board card component', () => { expect(wrapper.find('.confidential-icon').exists()).toBe(false); }); + it('does not render hidden issue icon', () => { + expect(findHiddenIssueIcon().exists()).toBe(false); + }); + it('renders issue ID with #', () => { expect(wrapper.find('.board-card-number').text()).toContain(`#${issue.iid}`); }); @@ -184,6 +193,30 @@ describe('Board card component', () => { }); }); + describe('hidden issue', () => { + beforeEach(() => { + wrapper.setProps({ + item: { + ...wrapper.props('item'), + hidden: true, + }, + }); + }); + + it('renders hidden issue icon', () => { + expect(findHiddenIssueIcon().exists()).toBe(true); + }); + + it('displays a tooltip which explains the meaning of the icon', () => { + const tooltip = getBinding(findHiddenIssueIcon().element, 'gl-tooltip'); + + expect(tooltip).toBeDefined(); + expect(findHiddenIssueIcon().attributes('title')).toBe( + 'This issue is hidden because its author has been banned', + ); + }); + }); + describe('with assignee', () => { describe('with avatar', () => { beforeEach(() => { diff --git a/spec/frontend/boards/board_list_deprecated_spec.js b/spec/frontend/boards/board_list_deprecated_spec.js deleted file mode 100644 index b71564f7858..00000000000 --- a/spec/frontend/boards/board_list_deprecated_spec.js +++ /dev/null @@ -1,274 +0,0 @@ -/* global List */ -/* global ListIssue */ -import MockAdapter from 'axios-mock-adapter'; -import Vue from 'vue'; -import waitForPromises from 'helpers/wait_for_promises'; -import BoardList from '~/boards/components/board_list_deprecated.vue'; -import eventHub from '~/boards/eventhub'; -import store from '~/boards/stores'; -import boardsStore from '~/boards/stores/boards_store'; -import axios from '~/lib/utils/axios_utils'; -import '~/boards/models/issue'; -import '~/boards/models/list'; -import { listObj, boardsMockInterceptor } from './mock_data'; - -const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listProps = {} }) => { - const el = document.createElement('div'); - - document.body.appendChild(el); - const mock = new MockAdapter(axios); - mock.onAny().reply(boardsMockInterceptor); - boardsStore.create(); - - const BoardListComp = Vue.extend(BoardList); - const list = new List({ ...listObj, ...listProps }); - const issue = new ListIssue({ - title: 'Testing', - id: 1, - iid: 1, - confidential: false, - labels: [], - assignees: [], - ...listIssueProps, - }); - if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) { - list.issuesSize = 1; - } - list.issues.push(issue); - - const component = new BoardListComp({ - el, - store, - propsData: { - disabled: false, - list, - issues: list.issues, - ...componentProps, - }, - provide: { - groupId: null, - rootPath: '/', - }, - }).$mount(); - - Vue.nextTick(() => { - done(); - }); - - return { component, mock }; -}; - -describe('Board list component', () => { - let mock; - let component; - let getIssues; - function generateIssues(compWrapper) { - for (let i = 1; i < 20; i += 1) { - const issue = { ...compWrapper.list.issues[0] }; - issue.id += i; - compWrapper.list.issues.push(issue); - } - } - - describe('When Expanded', () => { - beforeEach((done) => { - getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {})); - ({ mock, component } = createComponent({ done })); - }); - - afterEach(() => { - mock.restore(); - component.$destroy(); - }); - - it('loads first page of issues', () => { - return waitForPromises().then(() => { - expect(getIssues).toHaveBeenCalled(); - }); - }); - - it('renders component', () => { - expect(component.$el.classList.contains('board-list-component')).toBe(true); - }); - - it('renders loading icon', () => { - component.list.loading = true; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-list-loading')).not.toBeNull(); - }); - }); - - it('renders issues', () => { - expect(component.$el.querySelectorAll('.board-card').length).toBe(1); - }); - - it('sets data attribute with issue id', () => { - expect(component.$el.querySelector('.board-card').getAttribute('data-issue-id')).toBe('1'); - }); - - it('shows new issue form', () => { - component.toggleForm(); - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); - - expect(component.$el.querySelector('.is-smaller')).not.toBeNull(); - }); - }); - - it('shows new issue form after eventhub event', () => { - eventHub.$emit(`toggle-issue-form-${component.list.id}`); - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); - - expect(component.$el.querySelector('.is-smaller')).not.toBeNull(); - }); - }); - - it('does not show new issue form for closed list', () => { - component.list.type = 'closed'; - component.toggleForm(); - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-new-issue-form')).toBeNull(); - }); - }); - - it('shows count list item', () => { - component.showCount = true; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-list-count')).not.toBeNull(); - - expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe( - 'Showing all issues', - ); - }); - }); - - it('sets data attribute with invalid id', () => { - component.showCount = true; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-list-count').getAttribute('data-issue-id')).toBe( - '-1', - ); - }); - }); - - it('shows how many more issues to load', () => { - component.showCount = true; - component.list.issuesSize = 20; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe( - 'Showing 1 of 20 issues', - ); - }); - }); - - it('loads more issues after scrolling', () => { - jest.spyOn(component.list, 'nextPage').mockImplementation(() => {}); - generateIssues(component); - component.$refs.list.dispatchEvent(new Event('scroll')); - - return waitForPromises().then(() => { - expect(component.list.nextPage).toHaveBeenCalled(); - }); - }); - - it('does not load issues if already loading', () => { - component.list.nextPage = jest - .spyOn(component.list, 'nextPage') - .mockReturnValue(new Promise(() => {})); - - component.onScroll(); - component.onScroll(); - - return waitForPromises().then(() => { - expect(component.list.nextPage).toHaveBeenCalledTimes(1); - }); - }); - - it('shows loading more spinner', () => { - component.showCount = true; - component.list.loadingMore = true; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-list-count .gl-spinner')).not.toBeNull(); - }); - }); - }); - - describe('When Collapsed', () => { - beforeEach((done) => { - getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {})); - ({ mock, component } = createComponent({ - done, - listProps: { type: 'closed', collapsed: true, issuesSize: 50 }, - })); - generateIssues(component); - component.scrollHeight = jest.spyOn(component, 'scrollHeight').mockReturnValue(0); - }); - - afterEach(() => { - mock.restore(); - component.$destroy(); - }); - - it('does not load all issues', () => { - return waitForPromises().then(() => { - // Initial getIssues from list constructor - expect(getIssues).toHaveBeenCalledTimes(1); - }); - }); - }); - - describe('max issue count warning', () => { - beforeEach((done) => { - ({ mock, component } = createComponent({ - done, - listProps: { type: 'closed', collapsed: true, issuesSize: 50 }, - })); - }); - - afterEach(() => { - mock.restore(); - component.$destroy(); - }); - - describe('when issue count exceeds max issue count', () => { - it('sets background to bg-danger-100', () => { - component.list.issuesSize = 4; - component.list.maxIssueCount = 3; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.bg-danger-100')).not.toBeNull(); - }); - }); - }); - - describe('when list issue count does NOT exceed list max issue count', () => { - it('does not sets background to bg-danger-100', () => { - component.list.issuesSize = 2; - component.list.maxIssueCount = 3; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.bg-danger-100')).toBeNull(); - }); - }); - }); - - describe('when list max issue count is 0', () => { - it('does not sets background to bg-danger-100', () => { - component.list.maxIssueCount = 0; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.bg-danger-100')).toBeNull(); - }); - }); - }); - }); -}); diff --git a/spec/frontend/boards/board_new_issue_deprecated_spec.js b/spec/frontend/boards/board_new_issue_deprecated_spec.js deleted file mode 100644 index 3beaf870bf5..00000000000 --- a/spec/frontend/boards/board_new_issue_deprecated_spec.js +++ /dev/null @@ -1,211 +0,0 @@ -/* global List */ - -import { mount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import Vue from 'vue'; -import Vuex from 'vuex'; -import boardNewIssue from '~/boards/components/board_new_issue_deprecated.vue'; -import boardsStore from '~/boards/stores/boards_store'; -import axios from '~/lib/utils/axios_utils'; - -import '~/boards/models/list'; -import { listObj, boardsMockInterceptor } from './mock_data'; - -Vue.use(Vuex); - -describe('Issue boards new issue form', () => { - let wrapper; - let vm; - let list; - let mock; - let newIssueMock; - const promiseReturn = { - data: { - iid: 100, - }, - }; - - const submitIssue = () => { - const dummySubmitEvent = { - preventDefault() {}, - }; - wrapper.vm.$refs.submitButton = wrapper.find({ ref: 'submitButton' }); - return wrapper.vm.submit(dummySubmitEvent); - }; - - beforeEach(() => { - const BoardNewIssueComp = Vue.extend(boardNewIssue); - - mock = new MockAdapter(axios); - mock.onAny().reply(boardsMockInterceptor); - - boardsStore.create(); - - list = new List(listObj); - - newIssueMock = Promise.resolve(promiseReturn); - jest.spyOn(list, 'newIssue').mockImplementation(() => newIssueMock); - - const store = new Vuex.Store({ - getters: { isGroupBoard: () => false }, - }); - - wrapper = mount(BoardNewIssueComp, { - propsData: { - disabled: false, - list, - }, - store, - provide: { - groupId: null, - }, - }); - - vm = wrapper.vm; - - return Vue.nextTick(); - }); - - afterEach(() => { - wrapper.destroy(); - mock.restore(); - }); - - it('calls submit if submit button is clicked', () => { - jest.spyOn(wrapper.vm, 'submit').mockImplementation(); - vm.title = 'Testing Title'; - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(wrapper.vm.submit).toHaveBeenCalled(); - }); - }); - - it('disables submit button if title is empty', () => { - expect(wrapper.find({ ref: 'submitButton' }).props().disabled).toBe(true); - }); - - it('enables submit button if title is not empty', () => { - wrapper.setData({ title: 'Testing Title' }); - - return Vue.nextTick().then(() => { - expect(wrapper.find({ ref: 'input' }).element.value).toBe('Testing Title'); - expect(wrapper.find({ ref: 'submitButton' }).props().disabled).toBe(false); - }); - }); - - it('clears title after clicking cancel', () => { - wrapper.find({ ref: 'cancelButton' }).trigger('click'); - - return Vue.nextTick().then(() => { - expect(vm.title).toBe(''); - }); - }); - - it('does not create new issue if title is empty', () => { - return submitIssue().then(() => { - expect(list.newIssue).not.toHaveBeenCalled(); - }); - }); - - describe('submit success', () => { - it('creates new issue', () => { - wrapper.setData({ title: 'create issue' }); - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(list.newIssue).toHaveBeenCalled(); - }); - }); - - it('enables button after submit', () => { - jest.spyOn(wrapper.vm, 'submit').mockImplementation(); - wrapper.setData({ title: 'create issue' }); - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(wrapper.vm.$refs.submitButton.props().disabled).toBe(false); - }); - }); - - it('clears title after submit', () => { - wrapper.setData({ title: 'create issue' }); - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(vm.title).toBe(''); - }); - }); - - it('sets detail issue after submit', () => { - expect(boardsStore.detail.issue.title).toBe(undefined); - wrapper.setData({ title: 'create issue' }); - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(boardsStore.detail.issue.title).toBe('create issue'); - }); - }); - - it('sets detail list after submit', () => { - wrapper.setData({ title: 'create issue' }); - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(boardsStore.detail.list.id).toBe(list.id); - }); - }); - - it('sets detail weight after submit', () => { - boardsStore.weightFeatureAvailable = true; - wrapper.setData({ title: 'create issue' }); - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(boardsStore.detail.list.weight).toBe(list.weight); - }); - }); - - it('does not set detail weight after submit', () => { - boardsStore.weightFeatureAvailable = false; - wrapper.setData({ title: 'create issue' }); - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(boardsStore.detail.list.weight).toBe(list.weight); - }); - }); - }); - - describe('submit error', () => { - beforeEach(() => { - newIssueMock = Promise.reject(new Error('My hovercraft is full of eels!')); - vm.title = 'error'; - }); - - it('removes issue', () => { - const lengthBefore = list.issues.length; - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(list.issues.length).toBe(lengthBefore); - }); - }); - - it('shows error', () => { - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(vm.error).toBe(true); - }); - }); - }); -}); diff --git a/spec/frontend/boards/boards_store_spec.js b/spec/frontend/boards/boards_store_spec.js deleted file mode 100644 index 02881333273..00000000000 --- a/spec/frontend/boards/boards_store_spec.js +++ /dev/null @@ -1,1013 +0,0 @@ -import AxiosMockAdapter from 'axios-mock-adapter'; -import { TEST_HOST } from 'helpers/test_constants'; -import eventHub from '~/boards/eventhub'; - -import ListIssue from '~/boards/models/issue'; -import List from '~/boards/models/list'; -import boardsStore from '~/boards/stores/boards_store'; -import axios from '~/lib/utils/axios_utils'; -import { listObj, listObjDuplicate } from './mock_data'; - -jest.mock('js-cookie'); - -const createTestIssue = () => ({ - title: 'Testing', - id: 1, - iid: 1, - confidential: false, - labels: [], - assignees: [], -}); - -describe('boardsStore', () => { - 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 axiosMock; - - beforeEach(() => { - axiosMock = new AxiosMockAdapter(axios); - boardsStore.setEndpoints({ - ...endpoints, - boardId, - }); - }); - - afterEach(() => { - axiosMock.restore(); - }); - - const setupDefaultResponses = () => { - axiosMock - .onGet(`${endpoints.listsEndpoint}/${listObj.id}/issues?id=${listObj.id}&page=1`) - .reply(200, { issues: [createTestIssue()] }); - axiosMock.onPost(endpoints.listsEndpoint).reply(200, listObj); - axiosMock.onPut(); - }; - - describe('all', () => { - it('makes a request to fetch lists', () => { - axiosMock.onGet(endpoints.listsEndpoint).replyOnce(200, dummyResponse); - const expectedResponse = expect.objectContaining({ data: dummyResponse }); - - return expect(boardsStore.all()).resolves.toEqual(expectedResponse); - }); - - it('fails for error response', () => { - axiosMock.onGet(endpoints.listsEndpoint).replyOnce(500); - - return expect(boardsStore.all()).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(boardsStore.createList(entityId, entityType)) - .resolves.toEqual(expectedResponse) - .then(() => { - expect(requestSpy).toHaveBeenCalledWith(expectedRequest); - }); - }); - - it('fails for error response', () => { - requestSpy.mockReturnValue([500]); - - return expect(boardsStore.createList(entityId, entityType)) - .rejects.toThrow() - .then(() => { - expect(requestSpy).toHaveBeenCalledWith(expectedRequest); - }); - }); - }); - - describe('updateList', () => { - const id = 'David Webb'; - const position = 'unknown'; - const collapsed = false; - const expectedRequest = expect.objectContaining({ - data: JSON.stringify({ list: { position, collapsed } }), - }); - - 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(boardsStore.updateList(id, position, collapsed)) - .resolves.toEqual(expectedResponse) - .then(() => { - expect(requestSpy).toHaveBeenCalledWith(expectedRequest); - }); - }); - - it('fails for error response', () => { - requestSpy.mockReturnValue([500]); - - return expect(boardsStore.updateList(id, position, collapsed)) - .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(boardsStore.destroyList(id)) - .resolves.toEqual(expectedResponse) - .then(() => { - expect(requestSpy).toHaveBeenCalled(); - }); - }); - - it('fails for error response', () => { - requestSpy.mockReturnValue([500]); - - return expect(boardsStore.destroyList(id)) - .rejects.toThrow() - .then(() => { - expect(requestSpy).toHaveBeenCalled(); - }); - }); - }); - - describe('saveList', () => { - let list; - - beforeEach(() => { - list = new List(listObj); - setupDefaultResponses(); - }); - - it('makes a request to save a list', () => { - const expectedResponse = expect.objectContaining({ issues: [createTestIssue()] }); - const expectedListValue = { - id: listObj.id, - position: listObj.position, - type: listObj.list_type, - label: listObj.label, - }; - expect(list.id).toBe(listObj.id); - expect(list.position).toBe(listObj.position); - expect(list).toMatchObject(expectedListValue); - - return expect(boardsStore.saveList(list)).resolves.toEqual(expectedResponse); - }); - }); - - describe('getListIssues', () => { - let list; - - beforeEach(() => { - list = new List(listObj); - setupDefaultResponses(); - }); - - it('makes a request to get issues', () => { - const expectedResponse = expect.objectContaining({ issues: [createTestIssue()] }); - expect(list.issues).toEqual([]); - - return expect(boardsStore.getListIssues(list, true)).resolves.toEqual(expectedResponse); - }); - }); - - describe('getIssuesForList', () => { - const id = 'TOO-MUCH'; - const url = `${endpoints.listsEndpoint}/${id}/issues?id=${id}`; - - it('makes a request to fetch list issues', () => { - axiosMock.onGet(url).replyOnce(200, dummyResponse); - const expectedResponse = expect.objectContaining({ data: dummyResponse }); - - return expect(boardsStore.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(boardsStore.getIssuesForList(id, filter)).resolves.toEqual(expectedResponse); - }); - - it('fails for error response', () => { - axiosMock.onGet(url).replyOnce(500); - - return expect(boardsStore.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(boardsStore.moveIssue(id, fromListId, toListId, moveBeforeId, moveAfterId)) - .resolves.toEqual(expectedResponse) - .then(() => { - expect(requestSpy).toHaveBeenCalledWith(expectedRequest); - }); - }); - - it('fails for error response', () => { - requestSpy.mockReturnValue([500]); - - return expect(boardsStore.moveIssue(id, fromListId, toListId, moveBeforeId, moveAfterId)) - .rejects.toThrow() - .then(() => { - expect(requestSpy).toHaveBeenCalledWith(expectedRequest); - }); - }); - }); - - describe('newIssue', () => { - const id = 1; - 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(boardsStore.newIssue(id, issue)) - .resolves.toEqual(expectedResponse) - .then(() => { - expect(requestSpy).toHaveBeenCalledWith(expectedRequest); - }); - }); - - it('fails for error response', () => { - requestSpy.mockReturnValue([500]); - - return expect(boardsStore.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(boardsStore.getBacklog(requestParams)).resolves.toEqual(expectedResponse); - }); - - it('fails for error response', () => { - axiosMock.onGet(url).replyOnce(500); - - return expect(boardsStore.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(boardsStore.bulkUpdate(issueIds, extraData)) - .resolves.toEqual(expectedResponse) - .then(() => { - expect(requestSpy).toHaveBeenCalledWith(expectedRequest); - }); - }); - - it('fails for error response', () => { - requestSpy.mockReturnValue([500]); - - return expect(boardsStore.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(boardsStore.getIssueInfo(dummyEndpoint)).resolves.toEqual(expectedResponse); - }); - - it('fails for error response', () => { - axiosMock.onGet(dummyEndpoint).replyOnce(500); - - return expect(boardsStore.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(boardsStore.toggleIssueSubscription(dummyEndpoint)).resolves.toEqual( - expectedResponse, - ); - }); - - it('fails for error response', () => { - axiosMock.onPost(dummyEndpoint).replyOnce(500); - - return expect(boardsStore.toggleIssueSubscription(dummyEndpoint)).rejects.toThrow(); - }); - }); - - describe('recentBoards', () => { - const url = `${endpoints.recentBoardsEndpoint}.json`; - - it('makes a request to fetch all boards', () => { - axiosMock.onGet(url).replyOnce(200, dummyResponse); - const expectedResponse = expect.objectContaining({ data: dummyResponse }); - - return expect(boardsStore.recentBoards()).resolves.toEqual(expectedResponse); - }); - - it('fails for error response', () => { - axiosMock.onGet(url).replyOnce(500); - - return expect(boardsStore.recentBoards()).rejects.toThrow(); - }); - }); - - describe('when created', () => { - beforeEach(() => { - setupDefaultResponses(); - - jest.spyOn(boardsStore, 'moveIssue').mockReturnValue(Promise.resolve()); - jest.spyOn(boardsStore, 'moveMultipleIssues').mockReturnValue(Promise.resolve()); - - boardsStore.create(); - }); - - it('starts with a blank state', () => { - expect(boardsStore.state.lists.length).toBe(0); - }); - - describe('addList', () => { - it('sorts by position', () => { - boardsStore.addList({ position: 2 }); - boardsStore.addList({ position: 1 }); - - expect(boardsStore.state.lists.map(({ position }) => position)).toEqual([1, 2]); - }); - }); - - describe('toggleFilter', () => { - const dummyFilter = 'x=42'; - let updateTokensSpy; - - beforeEach(() => { - updateTokensSpy = jest.fn(); - eventHub.$once('updateTokens', updateTokensSpy); - - // prevent using window.history - jest.spyOn(boardsStore, 'updateFiltersUrl').mockReturnValue(); - }); - - it('adds the filter if it is not present', () => { - boardsStore.filter.path = 'something'; - - boardsStore.toggleFilter(dummyFilter); - - expect(boardsStore.filter.path).toEqual(`something&${dummyFilter}`); - expect(updateTokensSpy).toHaveBeenCalled(); - expect(boardsStore.updateFiltersUrl).toHaveBeenCalled(); - }); - - it('removes the filter if it is present', () => { - boardsStore.filter.path = `something&${dummyFilter}`; - - boardsStore.toggleFilter(dummyFilter); - - expect(boardsStore.filter.path).toEqual('something'); - expect(updateTokensSpy).toHaveBeenCalled(); - expect(boardsStore.updateFiltersUrl).toHaveBeenCalled(); - }); - }); - - describe('lists', () => { - it('creates new list without persisting to DB', () => { - expect(boardsStore.state.lists.length).toBe(0); - - boardsStore.addList(listObj); - - expect(boardsStore.state.lists.length).toBe(1); - }); - - it('finds list by ID', () => { - boardsStore.addList(listObj); - const list = boardsStore.findList('id', listObj.id); - - expect(list.id).toBe(listObj.id); - }); - - it('finds list by type', () => { - boardsStore.addList(listObj); - const list = boardsStore.findList('type', 'label'); - - expect(list).toBeDefined(); - }); - - it('finds list by label ID', () => { - boardsStore.addList(listObj); - const list = boardsStore.findListByLabelId(listObj.label.id); - - expect(list.id).toBe(listObj.id); - }); - - it('gets issue when new list added', () => { - boardsStore.addList(listObj); - const list = boardsStore.findList('id', listObj.id); - - expect(boardsStore.state.lists.length).toBe(1); - - return axios.waitForAll().then(() => { - expect(list.issues.length).toBe(1); - expect(list.issues[0].id).toBe(1); - }); - }); - - it('persists new list', () => { - boardsStore.new({ - title: 'Test', - list_type: 'label', - label: { - id: 1, - title: 'Testing', - color: 'red', - description: 'testing;', - }, - }); - - expect(boardsStore.state.lists.length).toBe(1); - - return axios.waitForAll().then(() => { - const list = boardsStore.findList('id', listObj.id); - - expect(list).toEqual( - expect.objectContaining({ - id: listObj.id, - position: 0, - }), - ); - }); - }); - - it('removes list from state', () => { - boardsStore.addList(listObj); - - expect(boardsStore.state.lists.length).toBe(1); - - boardsStore.removeList(listObj.id); - - expect(boardsStore.state.lists.length).toBe(0); - }); - - it('moves the position of lists', () => { - const listOne = boardsStore.addList(listObj); - boardsStore.addList(listObjDuplicate); - - expect(boardsStore.state.lists.length).toBe(2); - - boardsStore.moveList(listOne, [listObjDuplicate.id, listObj.id]); - - expect(listOne.position).toBe(1); - }); - - it('moves an issue from one list to another', () => { - const listOne = boardsStore.addList(listObj); - const listTwo = boardsStore.addList(listObjDuplicate); - - expect(boardsStore.state.lists.length).toBe(2); - - return axios.waitForAll().then(() => { - expect(listOne.issues.length).toBe(1); - expect(listTwo.issues.length).toBe(1); - - boardsStore.moveIssueToList(listOne, listTwo, listOne.findIssue(1)); - - expect(listOne.issues.length).toBe(0); - expect(listTwo.issues.length).toBe(1); - }); - }); - - it('moves an issue from backlog to a list', () => { - const backlog = boardsStore.addList({ - ...listObj, - list_type: 'backlog', - }); - const listTwo = boardsStore.addList(listObjDuplicate); - - expect(boardsStore.state.lists.length).toBe(2); - - return axios.waitForAll().then(() => { - expect(backlog.issues.length).toBe(1); - expect(listTwo.issues.length).toBe(1); - - boardsStore.moveIssueToList(backlog, listTwo, backlog.findIssue(1)); - - expect(backlog.issues.length).toBe(0); - expect(listTwo.issues.length).toBe(1); - }); - }); - - it('moves issue to top of another list', () => { - const listOne = boardsStore.addList(listObj); - const listTwo = boardsStore.addList(listObjDuplicate); - - expect(boardsStore.state.lists.length).toBe(2); - - return axios.waitForAll().then(() => { - listOne.issues[0].id = 2; - - expect(listOne.issues.length).toBe(1); - expect(listTwo.issues.length).toBe(1); - - boardsStore.moveIssueToList(listOne, listTwo, listOne.findIssue(2), 0); - - expect(listOne.issues.length).toBe(0); - expect(listTwo.issues.length).toBe(2); - expect(listTwo.issues[0].id).toBe(2); - expect(boardsStore.moveIssue).toHaveBeenCalledWith(2, listOne.id, listTwo.id, null, 1); - }); - }); - - it('moves issue to bottom of another list', () => { - const listOne = boardsStore.addList(listObj); - const listTwo = boardsStore.addList(listObjDuplicate); - - expect(boardsStore.state.lists.length).toBe(2); - - return axios.waitForAll().then(() => { - listOne.issues[0].id = 2; - - expect(listOne.issues.length).toBe(1); - expect(listTwo.issues.length).toBe(1); - - boardsStore.moveIssueToList(listOne, listTwo, listOne.findIssue(2), 1); - - expect(listOne.issues.length).toBe(0); - expect(listTwo.issues.length).toBe(2); - expect(listTwo.issues[1].id).toBe(2); - expect(boardsStore.moveIssue).toHaveBeenCalledWith(2, listOne.id, listTwo.id, 1, null); - }); - }); - - it('moves issue in list', () => { - const issue = new ListIssue({ - title: 'Testing', - id: 2, - iid: 2, - confidential: false, - labels: [], - assignees: [], - }); - const list = boardsStore.addList(listObj); - - return axios.waitForAll().then(() => { - list.addIssue(issue); - - expect(list.issues.length).toBe(2); - - boardsStore.moveIssueInList(list, issue, 0, 1, [1, 2]); - - expect(list.issues[0].id).toBe(2); - expect(boardsStore.moveIssue).toHaveBeenCalledWith(2, null, null, 1, null); - }); - }); - }); - - describe('setListDetail', () => { - it('sets the list detail', () => { - boardsStore.detail.list = 'not a list'; - - const dummyValue = 'new list'; - boardsStore.setListDetail(dummyValue); - - expect(boardsStore.detail.list).toEqual(dummyValue); - }); - }); - - describe('clearDetailIssue', () => { - it('resets issue details', () => { - boardsStore.detail.issue = 'something'; - - boardsStore.clearDetailIssue(); - - expect(boardsStore.detail.issue).toEqual({}); - }); - }); - - describe('setIssueDetail', () => { - it('sets issue details', () => { - boardsStore.detail.issue = 'some details'; - - const dummyValue = 'new details'; - boardsStore.setIssueDetail(dummyValue); - - expect(boardsStore.detail.issue).toEqual(dummyValue); - }); - }); - - describe('startMoving', () => { - it('stores list and issue', () => { - const dummyIssue = 'some issue'; - const dummyList = 'some list'; - - boardsStore.startMoving(dummyList, dummyIssue); - - expect(boardsStore.moving.issue).toEqual(dummyIssue); - expect(boardsStore.moving.list).toEqual(dummyList); - }); - }); - - describe('setTimeTrackingLimitToHours', () => { - it('sets the timeTracking.LimitToHours option', () => { - boardsStore.timeTracking.limitToHours = false; - - boardsStore.setTimeTrackingLimitToHours('true'); - - expect(boardsStore.timeTracking.limitToHours).toEqual(true); - }); - }); - - describe('setCurrentBoard', () => { - const dummyBoard = 'hoverboard'; - - it('sets the current board', () => { - const { state } = boardsStore; - state.currentBoard = null; - - boardsStore.setCurrentBoard(dummyBoard); - - expect(state.currentBoard).toEqual(dummyBoard); - }); - }); - - describe('toggleMultiSelect', () => { - let basicIssueObj; - - beforeAll(() => { - basicIssueObj = { id: 987654 }; - }); - - afterEach(() => { - boardsStore.clearMultiSelect(); - }); - - it('adds issue when not present', () => { - boardsStore.toggleMultiSelect(basicIssueObj); - - const selectedIds = boardsStore.multiSelect.list.map(({ id }) => id); - - expect(selectedIds.includes(basicIssueObj.id)).toEqual(true); - }); - - it('removes issue when issue is present', () => { - boardsStore.toggleMultiSelect(basicIssueObj); - let selectedIds = boardsStore.multiSelect.list.map(({ id }) => id); - - expect(selectedIds.includes(basicIssueObj.id)).toEqual(true); - - boardsStore.toggleMultiSelect(basicIssueObj); - selectedIds = boardsStore.multiSelect.list.map(({ id }) => id); - - expect(selectedIds.includes(basicIssueObj.id)).toEqual(false); - }); - }); - - describe('clearMultiSelect', () => { - it('clears all the multi selected issues', () => { - const issue1 = { id: 12345 }; - const issue2 = { id: 12346 }; - - boardsStore.toggleMultiSelect(issue1); - boardsStore.toggleMultiSelect(issue2); - - expect(boardsStore.multiSelect.list.length).toEqual(2); - - boardsStore.clearMultiSelect(); - - expect(boardsStore.multiSelect.list.length).toEqual(0); - }); - }); - - describe('moveMultipleIssuesToList', () => { - it('move issues on the new index', () => { - const listOne = boardsStore.addList(listObj); - const listTwo = boardsStore.addList(listObjDuplicate); - - expect(boardsStore.state.lists.length).toBe(2); - - return axios.waitForAll().then(() => { - expect(listOne.issues.length).toBe(1); - expect(listTwo.issues.length).toBe(1); - - boardsStore.moveMultipleIssuesToList({ - listFrom: listOne, - listTo: listTwo, - issues: listOne.issues, - newIndex: 0, - }); - - expect(listTwo.issues.length).toBe(1); - }); - }); - }); - - describe('moveMultipleIssuesInList', () => { - it('moves multiple issues in list', () => { - const issueObj = { - title: 'Issue #1', - id: 12345, - iid: 2, - confidential: false, - labels: [], - assignees: [], - }; - const issue1 = new ListIssue(issueObj); - const issue2 = new ListIssue({ - ...issueObj, - title: 'Issue #2', - id: 12346, - }); - - const list = boardsStore.addList(listObj); - - return axios.waitForAll().then(() => { - list.addIssue(issue1); - list.addIssue(issue2); - - expect(list.issues.length).toBe(3); - expect(list.issues[0].id).not.toBe(issue2.id); - - boardsStore.moveMultipleIssuesInList({ - list, - issues: [issue1, issue2], - oldIndicies: [0], - newIndex: 1, - idArray: [1, 12345, 12346], - }); - - expect(list.issues[0].id).toBe(issue1.id); - - expect(boardsStore.moveMultipleIssues).toHaveBeenCalledWith({ - ids: [issue1.id, issue2.id], - fromListId: null, - toListId: null, - moveBeforeId: 1, - moveAfterId: null, - }); - }); - }); - }); - - describe('addListIssue', () => { - let list; - const issue1 = new ListIssue({ - title: 'Testing', - id: 2, - iid: 2, - confidential: false, - labels: [ - { - color: '#ff0000', - description: 'testing;', - id: 5000, - priority: undefined, - textColor: 'white', - title: 'Test', - }, - ], - assignees: [], - }); - const issue2 = new ListIssue({ - title: 'Testing', - id: 1, - iid: 1, - confidential: false, - labels: [ - { - id: 1, - title: 'test', - color: 'red', - description: 'testing', - }, - ], - assignees: [ - { - id: 1, - name: 'name', - username: 'username', - avatar_url: 'http://avatar_url', - }, - ], - real_path: 'path/to/issue', - }); - - beforeEach(() => { - list = new List(listObj); - list.addIssue(issue1); - setupDefaultResponses(); - }); - - it('adds issues that are not already on the list', () => { - expect(list.findIssue(issue2.id)).toBe(undefined); - expect(list.issues).toEqual([issue1]); - - boardsStore.addListIssue(list, issue2); - expect(list.findIssue(issue2.id)).toBe(issue2); - expect(list.issues.length).toBe(2); - expect(list.issues).toEqual([issue1, issue2]); - }); - }); - - describe('updateIssue', () => { - let issue; - let patchSpy; - - beforeEach(() => { - issue = new ListIssue({ - title: 'Testing', - id: 1, - iid: 1, - confidential: false, - labels: [ - { - id: 1, - title: 'test', - color: 'red', - description: 'testing', - }, - ], - assignees: [ - { - id: 1, - name: 'name', - username: 'username', - avatar_url: 'http://avatar_url', - }, - ], - real_path: 'path/to/issue', - }); - - patchSpy = jest.fn().mockReturnValue([200, { labels: [] }]); - axiosMock.onPatch(`path/to/issue.json`).reply(({ data }) => patchSpy(JSON.parse(data))); - }); - - it('passes assignee ids when there are assignees', () => { - boardsStore.updateIssue(issue); - return boardsStore.updateIssue(issue).then(() => { - expect(patchSpy).toHaveBeenCalledWith({ - issue: { - milestone_id: null, - assignee_ids: [1], - label_ids: [1], - }, - }); - }); - }); - - it('passes assignee ids of [0] when there are no assignees', () => { - issue.removeAllAssignees(); - - return boardsStore.updateIssue(issue).then(() => { - expect(patchSpy).toHaveBeenCalledWith({ - issue: { - milestone_id: null, - assignee_ids: [0], - label_ids: [1], - }, - }); - }); - }); - }); - }); -}); diff --git a/spec/frontend/boards/components/board_add_new_column_spec.js b/spec/frontend/boards/components/board_add_new_column_spec.js index 61f210f566b..5fae1c4359f 100644 --- a/spec/frontend/boards/components/board_add_new_column_spec.js +++ b/spec/frontend/boards/components/board_add_new_column_spec.js @@ -48,7 +48,6 @@ describe('Board card layout', () => { ...actions, }, getters: { - shouldUseGraphQL: () => true, getListByLabelId: () => getListByLabelId, }, state: { diff --git a/spec/frontend/boards/components/board_app_spec.js b/spec/frontend/boards/components/board_app_spec.js new file mode 100644 index 00000000000..dee097bfb08 --- /dev/null +++ b/spec/frontend/boards/components/board_app_spec.js @@ -0,0 +1,54 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; + +import BoardApp from '~/boards/components/board_app.vue'; + +describe('BoardApp', () => { + let wrapper; + let store; + + Vue.use(Vuex); + + const createStore = ({ mockGetters = {} } = {}) => { + store = new Vuex.Store({ + state: {}, + actions: { + performSearch: jest.fn(), + }, + getters: { + isSidebarOpen: () => true, + ...mockGetters, + }, + }); + }; + + const createComponent = ({ provide = { disabled: true } } = {}) => { + wrapper = shallowMount(BoardApp, { + store, + provide: { + ...provide, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + store = null; + }); + + it("should have 'is-compact' class when sidebar is open", () => { + createStore(); + createComponent(); + + expect(wrapper.classes()).toContain('is-compact'); + }); + + it("should not have 'is-compact' class when sidebar is closed", () => { + createStore({ mockGetters: { isSidebarOpen: () => false } }); + createComponent(); + + expect(wrapper.classes()).not.toContain('is-compact'); + }); +}); diff --git a/spec/frontend/boards/components/board_card_deprecated_spec.js b/spec/frontend/boards/components/board_card_deprecated_spec.js deleted file mode 100644 index 266cbc7106d..00000000000 --- a/spec/frontend/boards/components/board_card_deprecated_spec.js +++ /dev/null @@ -1,219 +0,0 @@ -/* global List */ -/* global ListAssignee */ -/* global ListLabel */ - -import { mount } from '@vue/test-utils'; - -import MockAdapter from 'axios-mock-adapter'; -import waitForPromises from 'helpers/wait_for_promises'; -import BoardCardDeprecated from '~/boards/components/board_card_deprecated.vue'; -import issueCardInner from '~/boards/components/issue_card_inner_deprecated.vue'; -import eventHub from '~/boards/eventhub'; -import store from '~/boards/stores'; -import boardsStore from '~/boards/stores/boards_store'; -import axios from '~/lib/utils/axios_utils'; - -import sidebarEventHub from '~/sidebar/event_hub'; -import '~/boards/models/label'; -import '~/boards/models/assignee'; -import '~/boards/models/list'; -import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data'; - -describe('BoardCard', () => { - let wrapper; - let mock; - let list; - - const findIssueCardInner = () => wrapper.find(issueCardInner); - const findUserAvatarLink = () => wrapper.find(userAvatarLink); - - // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized - const mountComponent = (propsData) => { - wrapper = mount(BoardCardDeprecated, { - stubs: { - issueCardInner, - }, - store, - propsData: { - list, - issue: list.issues[0], - disabled: false, - index: 0, - ...propsData, - }, - provide: { - groupId: null, - rootPath: '/', - scopedLabelsAvailable: false, - }, - }); - }; - - const setupData = async () => { - list = new List(listObj); - boardsStore.create(); - boardsStore.detail.issue = {}; - const label1 = new ListLabel({ - id: 3, - title: 'testing 123', - color: '#000cff', - text_color: 'white', - description: 'test', - }); - await waitForPromises(); - - list.issues[0].labels.push(label1); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - mock.onAny().reply(boardsMockInterceptor); - setMockEndpoints(); - return setupData(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - list = null; - mock.restore(); - }); - - it('when details issue is empty does not show the element', () => { - mountComponent(); - expect(wrapper.find('[data-testid="board_card"').classes()).not.toContain('is-active'); - }); - - it('when detailIssue is equal to card issue shows the element', () => { - [boardsStore.detail.issue] = list.issues; - mountComponent(); - - expect(wrapper.classes()).toContain('is-active'); - }); - - it('when multiSelect does not contain issue removes multi select class', () => { - mountComponent(); - expect(wrapper.classes()).not.toContain('multi-select'); - }); - - it('when multiSelect contain issue add multi select class', () => { - boardsStore.multiSelect.list = [list.issues[0]]; - mountComponent(); - - expect(wrapper.classes()).toContain('multi-select'); - }); - - it('adds user-can-drag class if not disabled', () => { - mountComponent(); - expect(wrapper.classes()).toContain('user-can-drag'); - }); - - it('does not add user-can-drag class disabled', () => { - mountComponent({ disabled: true }); - - expect(wrapper.classes()).not.toContain('user-can-drag'); - }); - - it('does not add disabled class', () => { - mountComponent(); - expect(wrapper.classes()).not.toContain('is-disabled'); - }); - - it('adds disabled class is disabled is true', () => { - mountComponent({ disabled: true }); - - expect(wrapper.classes()).toContain('is-disabled'); - }); - - describe('mouse events', () => { - it('does not set detail issue if showDetail is false', () => { - mountComponent(); - expect(boardsStore.detail.issue).toEqual({}); - }); - - it('does not set detail issue if link is clicked', () => { - mountComponent(); - findIssueCardInner().find('a').trigger('mouseup'); - - expect(boardsStore.detail.issue).toEqual({}); - }); - - it('does not set detail issue if img is clicked', () => { - mountComponent({ - issue: { - ...list.issues[0], - assignees: [ - new ListAssignee({ - id: 1, - name: 'testing 123', - username: 'test', - avatar: 'test_image', - }), - ], - }, - }); - - findUserAvatarLink().trigger('mouseup'); - - expect(boardsStore.detail.issue).toEqual({}); - }); - - it('does not set detail issue if showDetail is false after mouseup', () => { - mountComponent(); - wrapper.trigger('mouseup'); - - expect(boardsStore.detail.issue).toEqual({}); - }); - - it('sets detail issue to card issue on mouse up', () => { - jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - - mountComponent(); - - wrapper.trigger('mousedown'); - wrapper.trigger('mouseup'); - - expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', wrapper.vm.issue, false); - expect(boardsStore.detail.list).toEqual(wrapper.vm.list); - }); - - it('resets detail issue to empty if already set', () => { - jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - const [issue] = list.issues; - boardsStore.detail.issue = issue; - mountComponent(); - - wrapper.trigger('mousedown'); - wrapper.trigger('mouseup'); - - expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue', false); - }); - }); - - describe('sidebarHub events', () => { - it('closes all sidebars before showing an issue if no issues are opened', () => { - jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {}); - boardsStore.detail.issue = {}; - mountComponent(); - - // sets conditional so that event is emitted. - wrapper.trigger('mousedown'); - - wrapper.trigger('mouseup'); - - expect(sidebarEventHub.$emit).toHaveBeenCalledWith('sidebar.closeAll'); - }); - - it('it does not closes all sidebars before showing an issue if an issue is opened', () => { - jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {}); - const [issue] = list.issues; - boardsStore.detail.issue = issue; - mountComponent(); - - wrapper.trigger('mousedown'); - - expect(sidebarEventHub.$emit).not.toHaveBeenCalledWith('sidebar.closeAll'); - }); - }); -}); diff --git a/spec/frontend/boards/components/board_card_layout_deprecated_spec.js b/spec/frontend/boards/components/board_card_layout_deprecated_spec.js deleted file mode 100644 index 9853c9f434f..00000000000 --- a/spec/frontend/boards/components/board_card_layout_deprecated_spec.js +++ /dev/null @@ -1,158 +0,0 @@ -/* global List */ -/* global ListLabel */ - -import { createLocalVue, shallowMount } from '@vue/test-utils'; - -import MockAdapter from 'axios-mock-adapter'; -import Vuex from 'vuex'; -import waitForPromises from 'helpers/wait_for_promises'; - -import '~/boards/models/label'; -import '~/boards/models/assignee'; -import '~/boards/models/list'; -import BoardCardLayout from '~/boards/components/board_card_layout_deprecated.vue'; -import issueCardInner from '~/boards/components/issue_card_inner_deprecated.vue'; -import { ISSUABLE } from '~/boards/constants'; -import boardsVuexStore from '~/boards/stores'; -import boardsStore from '~/boards/stores/boards_store'; -import axios from '~/lib/utils/axios_utils'; -import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data'; - -describe('Board card layout', () => { - let wrapper; - let mock; - let list; - let store; - - const localVue = createLocalVue(); - localVue.use(Vuex); - - const createStore = ({ getters = {}, actions = {} } = {}) => { - store = new Vuex.Store({ - ...boardsVuexStore, - actions, - getters, - }); - }; - - // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized - const mountComponent = ({ propsData = {}, provide = {} } = {}) => { - wrapper = shallowMount(BoardCardLayout, { - localVue, - stubs: { - issueCardInner, - }, - store, - propsData: { - list, - issue: list.issues[0], - disabled: false, - index: 0, - ...propsData, - }, - provide: { - groupId: null, - rootPath: '/', - scopedLabelsAvailable: false, - ...provide, - }, - }); - }; - - const setupData = () => { - list = new List(listObj); - boardsStore.create(); - boardsStore.detail.issue = {}; - const label1 = new ListLabel({ - id: 3, - title: 'testing 123', - color: '#000cff', - text_color: 'white', - description: 'test', - }); - return waitForPromises().then(() => { - list.issues[0].labels.push(label1); - }); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - mock.onAny().reply(boardsMockInterceptor); - setMockEndpoints(); - return setupData(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - list = null; - mock.restore(); - }); - - describe('mouse events', () => { - it('sets showDetail to true on mousedown', async () => { - createStore(); - mountComponent(); - - wrapper.trigger('mousedown'); - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.showDetail).toBe(true); - }); - - it('sets showDetail to false on mousemove', async () => { - createStore(); - mountComponent(); - wrapper.trigger('mousedown'); - await wrapper.vm.$nextTick(); - expect(wrapper.vm.showDetail).toBe(true); - wrapper.trigger('mousemove'); - await wrapper.vm.$nextTick(); - expect(wrapper.vm.showDetail).toBe(false); - }); - - it("calls 'setActiveId' when 'graphqlBoardLists' feature flag is turned on", async () => { - const setActiveId = jest.fn(); - createStore({ - actions: { - setActiveId, - }, - }); - mountComponent({ - provide: { - glFeatures: { graphqlBoardLists: true }, - }, - }); - - wrapper.trigger('mouseup'); - await wrapper.vm.$nextTick(); - - expect(setActiveId).toHaveBeenCalledTimes(1); - expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), { - id: list.issues[0].id, - sidebarType: ISSUABLE, - }); - }); - - it("calls 'setActiveId' when epic swimlanes is active", async () => { - const setActiveId = jest.fn(); - const isSwimlanesOn = () => true; - createStore({ - getters: { isSwimlanesOn }, - actions: { - setActiveId, - }, - }); - mountComponent(); - - wrapper.trigger('mouseup'); - await wrapper.vm.$nextTick(); - - expect(setActiveId).toHaveBeenCalledTimes(1); - expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), { - id: list.issues[0].id, - sidebarType: ISSUABLE, - }); - }); - }); -}); diff --git a/spec/frontend/boards/components/board_column_deprecated_spec.js b/spec/frontend/boards/components/board_column_deprecated_spec.js deleted file mode 100644 index e6d65e48c3f..00000000000 --- a/spec/frontend/boards/components/board_column_deprecated_spec.js +++ /dev/null @@ -1,106 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import AxiosMockAdapter from 'axios-mock-adapter'; -import Vue, { nextTick } from 'vue'; - -import { TEST_HOST } from 'helpers/test_constants'; -import { listObj } from 'jest/boards/mock_data'; -import Board from '~/boards/components/board_column_deprecated.vue'; -import { ListType } from '~/boards/constants'; -import List from '~/boards/models/list'; -import axios from '~/lib/utils/axios_utils'; - -describe('Board Column Component', () => { - let wrapper; - let axiosMock; - - beforeEach(() => { - window.gon = {}; - axiosMock = new AxiosMockAdapter(axios); - axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] }); - }); - - afterEach(() => { - axiosMock.restore(); - - wrapper.destroy(); - - localStorage.clear(); - }); - - const createComponent = ({ - listType = ListType.backlog, - collapsed = false, - highlighted = false, - withLocalStorage = true, - } = {}) => { - const boardId = '1'; - - const listMock = { - ...listObj, - list_type: listType, - highlighted, - collapsed, - }; - - if (listType === ListType.assignee) { - delete listMock.label; - listMock.user = {}; - } - - // Making List reactive - const list = Vue.observable(new List(listMock)); - - if (withLocalStorage) { - localStorage.setItem( - `boards.${boardId}.${list.type}.${list.id}.expanded`, - (!collapsed).toString(), - ); - } - - wrapper = shallowMount(Board, { - propsData: { - boardId, - disabled: false, - list, - }, - provide: { - boardId, - }, - }); - }; - - const isExpandable = () => wrapper.classes('is-expandable'); - const isCollapsed = () => wrapper.classes('is-collapsed'); - - describe('Given different list types', () => { - it('is expandable when List Type is `backlog`', () => { - createComponent({ listType: ListType.backlog }); - - expect(isExpandable()).toBe(true); - }); - }); - - describe('expanded / collapsed column', () => { - it('has class is-collapsed when list is collapsed', () => { - createComponent({ collapsed: false }); - - expect(wrapper.vm.list.isExpanded).toBe(true); - }); - - it('does not have class is-collapsed when list is expanded', () => { - createComponent({ collapsed: true }); - - expect(isCollapsed()).toBe(true); - }); - }); - - describe('highlighting', () => { - it('scrolls to column when highlighted', async () => { - createComponent({ highlighted: true }); - - await nextTick(); - - expect(wrapper.element.scrollIntoView).toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js index 5a799b6388e..f535679b8a0 100644 --- a/spec/frontend/boards/components/board_content_spec.js +++ b/spec/frontend/boards/components/board_content_spec.js @@ -5,9 +5,10 @@ import Draggable from 'vuedraggable'; import Vuex from 'vuex'; import EpicsSwimlanes from 'ee_component/boards/components/epics_swimlanes.vue'; import getters from 'ee_else_ce/boards/stores/getters'; -import BoardColumnDeprecated from '~/boards/components/board_column_deprecated.vue'; +import BoardColumn from '~/boards/components/board_column.vue'; import BoardContent from '~/boards/components/board_content.vue'; -import { mockLists, mockListsWithModel } from '../mock_data'; +import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue'; +import { mockLists } from '../mock_data'; Vue.use(Vuex); @@ -23,6 +24,7 @@ describe('BoardContent', () => { isShowingEpicsSwimlanes: false, boardLists: mockLists, error: undefined, + issuableType: 'issue', }; const createStore = (state = defaultState) => { @@ -33,25 +35,19 @@ describe('BoardContent', () => { }); }; - const createComponent = ({ - state, - props = {}, - graphqlBoardListsEnabled = false, - canAdminList = true, - } = {}) => { + const createComponent = ({ state, props = {}, canAdminList = true } = {}) => { const store = createStore({ ...defaultState, ...state, }); wrapper = shallowMount(BoardContent, { propsData: { - lists: mockListsWithModel, + lists: mockLists, disabled: false, ...props, }, provide: { canAdminList, - glFeatures: { graphqlBoardLists: graphqlBoardListsEnabled }, }, store, }); @@ -61,53 +57,48 @@ describe('BoardContent', () => { wrapper.destroy(); }); - it('renders a BoardColumnDeprecated component per list', () => { - createComponent(); + describe('default', () => { + beforeEach(() => { + createComponent(); + }); - expect(wrapper.findAllComponents(BoardColumnDeprecated)).toHaveLength( - mockListsWithModel.length, - ); - }); + it('renders a BoardColumn component per list', () => { + expect(wrapper.findAllComponents(BoardColumn)).toHaveLength(mockLists.length); + }); - it('does not display EpicsSwimlanes component', () => { - createComponent(); + it('renders BoardContentSidebar', () => { + expect(wrapper.find(BoardContentSidebar).exists()).toBe(true); + }); - expect(wrapper.find(EpicsSwimlanes).exists()).toBe(false); - expect(wrapper.find(GlAlert).exists()).toBe(false); + it('does not display EpicsSwimlanes component', () => { + expect(wrapper.find(EpicsSwimlanes).exists()).toBe(false); + expect(wrapper.find(GlAlert).exists()).toBe(false); + }); }); - describe('graphqlBoardLists feature flag enabled', () => { + describe('when issuableType is not issue', () => { beforeEach(() => { - createComponent({ graphqlBoardListsEnabled: true }); - gon.features = { - graphqlBoardLists: true, - }; + createComponent({ state: { issuableType: 'foo' } }); }); - describe('can admin list', () => { - beforeEach(() => { - createComponent({ graphqlBoardListsEnabled: true, canAdminList: true }); - }); - - it('renders draggable component', () => { - expect(wrapper.find(Draggable).exists()).toBe(true); - }); + it('does not render BoardContentSidebar', () => { + expect(wrapper.find(BoardContentSidebar).exists()).toBe(false); }); + }); - describe('can not admin list', () => { - beforeEach(() => { - createComponent({ graphqlBoardListsEnabled: true, canAdminList: false }); - }); + describe('can admin list', () => { + beforeEach(() => { + createComponent({ canAdminList: true }); + }); - it('does not render draggable component', () => { - expect(wrapper.find(Draggable).exists()).toBe(false); - }); + it('renders draggable component', () => { + expect(wrapper.find(Draggable).exists()).toBe(true); }); }); - describe('graphqlBoardLists feature flag disabled', () => { + describe('can not admin list', () => { beforeEach(() => { - createComponent({ graphqlBoardListsEnabled: false }); + createComponent({ canAdminList: false }); }); it('does not render draggable component', () => { diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js index 50f86e92adb..dc93890f27a 100644 --- a/spec/frontend/boards/components/board_filtered_search_spec.js +++ b/spec/frontend/boards/components/board_filtered_search_spec.js @@ -2,7 +2,6 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue'; -import { createStore } from '~/boards/stores'; import * as urlUtility from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; @@ -44,6 +43,12 @@ describe('BoardFilteredSearch', () => { ]; const createComponent = ({ initialFilterParams = {} } = {}) => { + store = new Vuex.Store({ + actions: { + performSearch: jest.fn(), + }, + }); + wrapper = shallowMount(BoardFilteredSearch, { provide: { initialFilterParams, fullPath: '' }, store, @@ -55,22 +60,15 @@ describe('BoardFilteredSearch', () => { const findFilteredSearch = () => wrapper.findComponent(FilteredSearchBarRoot); - beforeEach(() => { - // this needed for actions call for performSearch - window.gon = { features: {} }; - }); - afterEach(() => { wrapper.destroy(); }); describe('default', () => { beforeEach(() => { - store = createStore(); + createComponent(); jest.spyOn(store, 'dispatch'); - - createComponent(); }); it('renders FilteredSearch', () => { @@ -103,8 +101,6 @@ describe('BoardFilteredSearch', () => { describe('when searching', () => { beforeEach(() => { - store = createStore(); - createComponent(); jest.spyOn(wrapper.vm, 'performSearch').mockImplementation(); @@ -133,11 +129,9 @@ describe('BoardFilteredSearch', () => { describe('when url params are already set', () => { beforeEach(() => { - store = createStore(); + createComponent({ initialFilterParams: { authorUsername: 'root', labelName: ['label'] } }); jest.spyOn(store, 'dispatch'); - - createComponent({ initialFilterParams: { authorUsername: 'root', labelName: ['label'] } }); }); it('passes the correct props to FilterSearchBar', () => { diff --git a/spec/frontend/boards/components/board_list_header_deprecated_spec.js b/spec/frontend/boards/components/board_list_header_deprecated_spec.js deleted file mode 100644 index db79e67fe78..00000000000 --- a/spec/frontend/boards/components/board_list_header_deprecated_spec.js +++ /dev/null @@ -1,174 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import AxiosMockAdapter from 'axios-mock-adapter'; -import Vue from 'vue'; - -import { TEST_HOST } from 'helpers/test_constants'; -import { listObj } from 'jest/boards/mock_data'; -import BoardListHeader from '~/boards/components/board_list_header_deprecated.vue'; -import { ListType } from '~/boards/constants'; -import List from '~/boards/models/list'; -import axios from '~/lib/utils/axios_utils'; - -describe('Board List Header Component', () => { - let wrapper; - let axiosMock; - - beforeEach(() => { - window.gon = {}; - axiosMock = new AxiosMockAdapter(axios); - axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] }); - }); - - afterEach(() => { - axiosMock.restore(); - - wrapper.destroy(); - - localStorage.clear(); - }); - - const createComponent = ({ - listType = ListType.backlog, - collapsed = false, - withLocalStorage = true, - currentUserId = 1, - } = {}) => { - const boardId = '1'; - - const listMock = { - ...listObj, - list_type: listType, - collapsed, - }; - - if (listType === ListType.assignee) { - delete listMock.label; - listMock.user = {}; - } - - // Making List reactive - const list = Vue.observable(new List(listMock)); - - if (withLocalStorage) { - localStorage.setItem( - `boards.${boardId}.${list.type}.${list.id}.expanded`, - (!collapsed).toString(), - ); - } - - wrapper = shallowMount(BoardListHeader, { - propsData: { - disabled: false, - list, - }, - provide: { - boardId, - currentUserId, - }, - }); - }; - - const isCollapsed = () => !wrapper.props().list.isExpanded; - const isExpanded = () => wrapper.vm.list.isExpanded; - - const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' }); - const findCaret = () => wrapper.find('.board-title-caret'); - - describe('Add issue button', () => { - const hasNoAddButton = [ListType.closed]; - const hasAddButton = [ - ListType.backlog, - ListType.label, - ListType.milestone, - ListType.iteration, - ListType.assignee, - ]; - - it.each(hasNoAddButton)('does not render when List Type is `%s`', (listType) => { - createComponent({ listType }); - - expect(findAddIssueButton().exists()).toBe(false); - }); - - it.each(hasAddButton)('does render when List Type is `%s`', (listType) => { - createComponent({ listType }); - - expect(findAddIssueButton().exists()).toBe(true); - }); - - it('has a test for each list type', () => { - Object.values(ListType).forEach((value) => { - expect([...hasAddButton, ...hasNoAddButton]).toContain(value); - }); - }); - - it('does not render when logged out', () => { - createComponent({ - currentUserId: null, - }); - - expect(findAddIssueButton().exists()).toBe(false); - }); - }); - - describe('expanding / collapsing the column', () => { - it('does not collapse when clicking the header', () => { - createComponent(); - - expect(isCollapsed()).toBe(false); - wrapper.find('[data-testid="board-list-header"]').trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(isCollapsed()).toBe(false); - }); - }); - - it('collapses expanded Column when clicking the collapse icon', () => { - createComponent(); - - expect(isExpanded()).toBe(true); - findCaret().vm.$emit('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(isCollapsed()).toBe(true); - }); - }); - - it('expands collapsed Column when clicking the expand icon', () => { - createComponent({ collapsed: true }); - - expect(isCollapsed()).toBe(true); - findCaret().vm.$emit('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(isCollapsed()).toBe(false); - }); - }); - - it("when logged in it calls list update and doesn't set localStorage", () => { - jest.spyOn(List.prototype, 'update'); - - createComponent({ withLocalStorage: false }); - - findCaret().vm.$emit('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.list.update).toHaveBeenCalledTimes(1); - expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null); - }); - }); - - it("when logged out it doesn't call list update and sets localStorage", () => { - jest.spyOn(List.prototype, 'update'); - - createComponent({ currentUserId: null }); - - findCaret().vm.$emit('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.list.update).not.toHaveBeenCalled(); - expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded())); - }); - }); - }); -}); diff --git a/spec/frontend/boards/components/board_settings_sidebar_spec.js b/spec/frontend/boards/components/board_settings_sidebar_spec.js index 20a08be6c19..46dd109ffb1 100644 --- a/spec/frontend/boards/components/board_settings_sidebar_spec.js +++ b/spec/frontend/boards/components/board_settings_sidebar_spec.js @@ -1,38 +1,55 @@ -import '~/boards/models/list'; import { GlDrawer, GlLabel } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; +import { shallowMount } from '@vue/test-utils'; import { MountingPortal } from 'portal-vue'; +import Vue from 'vue'; import Vuex from 'vuex'; +import { stubComponent } from 'helpers/stub_component'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue'; import { inactiveId, LIST } from '~/boards/constants'; -import { createStore } from '~/boards/stores'; -import boardsStore from '~/boards/stores/boards_store'; +import actions from '~/boards/stores/actions'; +import getters from '~/boards/stores/getters'; +import mutations from '~/boards/stores/mutations'; import sidebarEventHub from '~/sidebar/event_hub'; +import { mockLabelList } from '../mock_data'; -const localVue = createLocalVue(); - -localVue.use(Vuex); +Vue.use(Vuex); describe('BoardSettingsSidebar', () => { let wrapper; - let mock; - let store; - const labelTitle = 'test'; - const labelColor = '#FFFF'; - const listId = 1; + const labelTitle = mockLabelList.label.title; + const labelColor = mockLabelList.label.color; + const listId = mockLabelList.id; const findRemoveButton = () => wrapper.findByTestId('remove-list'); - const createComponent = ({ canAdminList = false } = {}) => { + const createComponent = ({ + canAdminList = false, + list = {}, + sidebarType = LIST, + activeId = inactiveId, + } = {}) => { + const boardLists = { + [listId]: list, + }; + const store = new Vuex.Store({ + state: { sidebarType, activeId, boardLists }, + getters, + mutations, + actions, + }); + wrapper = extendedWrapper( shallowMount(BoardSettingsSidebar, { store, - localVue, provide: { canAdminList, + scopedLabelsAvailable: false, + }, + stubs: { + GlDrawer: stubComponent(GlDrawer, { + template: '<div><slot name="header"></slot><slot></slot></div>', + }), }, }), ); @@ -40,16 +57,10 @@ describe('BoardSettingsSidebar', () => { const findLabel = () => wrapper.find(GlLabel); const findDrawer = () => wrapper.find(GlDrawer); - beforeEach(() => { - store = createStore(); - store.state.activeId = inactiveId; - store.state.sidebarType = LIST; - boardsStore.create(); - }); - afterEach(() => { jest.restoreAllMocks(); wrapper.destroy(); + wrapper = null; }); it('finds a MountingPortal component', () => { @@ -100,86 +111,40 @@ describe('BoardSettingsSidebar', () => { }); describe('when activeId is greater than zero', () => { - beforeEach(() => { - mock = new MockAdapter(axios); - - boardsStore.addList({ - id: listId, - label: { title: labelTitle, color: labelColor }, - list_type: 'label', - }); - store.state.activeId = 1; - store.state.sidebarType = LIST; - }); - - afterEach(() => { - boardsStore.removeList(listId); - }); - - it('renders GlDrawer with open false', () => { - createComponent(); + it('renders GlDrawer with open true', () => { + createComponent({ list: mockLabelList, activeId: listId }); expect(findDrawer().props('open')).toBe(true); }); }); - describe('when activeId is in boardsStore', () => { - beforeEach(() => { - mock = new MockAdapter(axios); - - boardsStore.addList({ - id: listId, - label: { title: labelTitle, color: labelColor }, - list_type: 'label', - }); - - store.state.activeId = listId; - store.state.sidebarType = LIST; - - createComponent(); - }); - - afterEach(() => { - mock.restore(); - }); - + describe('when activeId is in state', () => { it('renders label title', () => { + createComponent({ list: mockLabelList, activeId: listId }); + expect(findLabel().props('title')).toBe(labelTitle); }); it('renders label background color', () => { + createComponent({ list: mockLabelList, activeId: listId }); + expect(findLabel().props('backgroundColor')).toBe(labelColor); }); }); - describe('when activeId is not in boardsStore', () => { - beforeEach(() => { - mock = new MockAdapter(axios); - - boardsStore.addList({ id: listId, label: { title: labelTitle, color: labelColor } }); - - store.state.activeId = inactiveId; - - createComponent(); - }); - - afterEach(() => { - mock.restore(); - }); - + describe('when activeId is not in state', () => { it('does not render GlLabel', () => { + createComponent({ list: mockLabelList }); + expect(findLabel().exists()).toBe(false); }); }); }); describe('when sidebarType is not List', () => { - beforeEach(() => { - store.state.sidebarType = ''; - createComponent(); - }); - it('does not render GlDrawer', () => { + createComponent({ sidebarType: '' }); + expect(findDrawer().exists()).toBe(false); }); }); @@ -191,20 +156,9 @@ describe('BoardSettingsSidebar', () => { }); describe('when user can admin the boards list', () => { - beforeEach(() => { - store.state.activeId = listId; - store.state.sidebarType = LIST; - - boardsStore.addList({ - id: listId, - label: { title: labelTitle, color: labelColor }, - list_type: 'label', - }); - - createComponent({ canAdminList: true }); - }); - it('renders "Remove list" button', () => { + createComponent({ canAdminList: true, activeId: listId, list: mockLabelList }); + expect(findRemoveButton().exists()).toBe(true); }); }); diff --git a/spec/frontend/boards/components/boards_selector_deprecated_spec.js b/spec/frontend/boards/components/boards_selector_deprecated_spec.js deleted file mode 100644 index cc078861d75..00000000000 --- a/spec/frontend/boards/components/boards_selector_deprecated_spec.js +++ /dev/null @@ -1,214 +0,0 @@ -import { GlDropdown, GlLoadingIcon, GlDropdownSectionHeader } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { TEST_HOST } from 'spec/test_constants'; -import BoardsSelector from '~/boards/components/boards_selector_deprecated.vue'; -import boardsStore from '~/boards/stores/boards_store'; - -const throttleDuration = 1; - -function boardGenerator(n) { - return new Array(n).fill().map((board, index) => { - const id = `${index}`; - const name = `board${id}`; - - return { - id, - name, - }; - }); -} - -describe('BoardsSelector', () => { - let wrapper; - let allBoardsResponse; - let recentBoardsResponse; - const boards = boardGenerator(20); - const recentBoards = boardGenerator(5); - - const fillSearchBox = (filterTerm) => { - const searchBox = wrapper.find({ ref: 'searchBox' }); - const searchBoxInput = searchBox.find('input'); - searchBoxInput.setValue(filterTerm); - searchBoxInput.trigger('input'); - }; - - const getDropdownItems = () => wrapper.findAll('.js-dropdown-item'); - const getDropdownHeaders = () => wrapper.findAll(GlDropdownSectionHeader); - const getLoadingIcon = () => wrapper.find(GlLoadingIcon); - const findDropdown = () => wrapper.find(GlDropdown); - - beforeEach(() => { - const $apollo = { - queries: { - boards: { - loading: false, - }, - }, - }; - - boardsStore.setEndpoints({ - boardsEndpoint: '', - recentBoardsEndpoint: '', - listsEndpoint: '', - bulkUpdatePath: '', - boardId: '', - }); - - allBoardsResponse = Promise.resolve({ - data: { - group: { - boards: { - edges: boards.map((board) => ({ node: board })), - }, - }, - }, - }); - recentBoardsResponse = Promise.resolve({ - data: recentBoards, - }); - - boardsStore.allBoards = jest.fn(() => allBoardsResponse); - boardsStore.recentBoards = jest.fn(() => recentBoardsResponse); - - wrapper = mount(BoardsSelector, { - propsData: { - throttleDuration, - currentBoard: { - id: 1, - name: 'Development', - milestone_id: null, - weight: null, - assignee_id: null, - labels: [], - }, - boardBaseUrl: `${TEST_HOST}/board/base/url`, - hasMissingBoards: false, - canAdminBoard: true, - multipleIssueBoardsAvailable: true, - labelsPath: `${TEST_HOST}/labels/path`, - labelsWebUrl: `${TEST_HOST}/labels`, - projectId: 42, - groupId: 19, - scopedIssueBoardFeatureEnabled: true, - weights: [], - }, - mocks: { $apollo }, - attachTo: document.body, - }); - - wrapper.vm.$apollo.addSmartQuery = jest.fn((_, options) => { - wrapper.setData({ - [options.loadingKey]: true, - }); - }); - - // Emits gl-dropdown show event to simulate the dropdown is opened at initialization time - findDropdown().vm.$emit('show'); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('loading', () => { - // we are testing loading state, so don't resolve responses until after the tests - afterEach(() => { - return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick()); - }); - - it('shows loading spinner', () => { - expect(getDropdownHeaders()).toHaveLength(0); - expect(getDropdownItems()).toHaveLength(0); - expect(getLoadingIcon().exists()).toBe(true); - }); - }); - - describe('loaded', () => { - beforeEach(async () => { - await wrapper.setData({ - loadingBoards: false, - }); - return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick()); - }); - - it('hides loading spinner', () => { - expect(getLoadingIcon().exists()).toBe(false); - }); - - describe('filtering', () => { - beforeEach(() => { - wrapper.setData({ - boards, - }); - - return nextTick(); - }); - - it('shows all boards without filtering', () => { - expect(getDropdownItems()).toHaveLength(boards.length + recentBoards.length); - }); - - it('shows only matching boards when filtering', () => { - const filterTerm = 'board1'; - const expectedCount = boards.filter((board) => board.name.includes(filterTerm)).length; - - fillSearchBox(filterTerm); - - return nextTick().then(() => { - expect(getDropdownItems()).toHaveLength(expectedCount); - }); - }); - - it('shows message if there are no matching boards', () => { - fillSearchBox('does not exist'); - - return nextTick().then(() => { - expect(getDropdownItems()).toHaveLength(0); - expect(wrapper.text().includes('No matching boards found')).toBe(true); - }); - }); - }); - - describe('recent boards section', () => { - it('shows only when boards are greater than 10', () => { - wrapper.setData({ - boards, - }); - - return nextTick().then(() => { - expect(getDropdownHeaders()).toHaveLength(2); - }); - }); - - it('does not show when boards are less than 10', () => { - wrapper.setData({ - boards: boards.slice(0, 5), - }); - - return nextTick().then(() => { - expect(getDropdownHeaders()).toHaveLength(0); - }); - }); - - it('does not show when recentBoards api returns empty array', () => { - wrapper.setData({ - recentBoards: [], - }); - - return nextTick().then(() => { - expect(getDropdownHeaders()).toHaveLength(0); - }); - }); - - it('does not show when search is active', () => { - fillSearchBox('Random string'); - - return nextTick().then(() => { - expect(getDropdownHeaders()).toHaveLength(0); - }); - }); - }); - }); -}); diff --git a/spec/frontend/boards/components/issue_time_estimate_deprecated_spec.js b/spec/frontend/boards/components/issue_time_estimate_deprecated_spec.js deleted file mode 100644 index fafebaf3a4e..00000000000 --- a/spec/frontend/boards/components/issue_time_estimate_deprecated_spec.js +++ /dev/null @@ -1,64 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import IssueTimeEstimate from '~/boards/components/issue_time_estimate_deprecated.vue'; -import boardsStore from '~/boards/stores/boards_store'; - -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, - }, - }); - }); - - 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, - }, - }); - }); - - 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_deprecated_spec.js b/spec/frontend/boards/issue_card_deprecated_spec.js deleted file mode 100644 index 909be275030..00000000000 --- a/spec/frontend/boards/issue_card_deprecated_spec.js +++ /dev/null @@ -1,332 +0,0 @@ -/* global ListAssignee, ListLabel, ListIssue */ -import { GlLabel } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { range } from 'lodash'; -import '~/boards/models/label'; -import '~/boards/models/assignee'; -import '~/boards/models/issue'; -import '~/boards/models/list'; -import IssueCardInner from '~/boards/components/issue_card_inner_deprecated.vue'; -import store from '~/boards/stores'; -import { listObj } from './mock_data'; - -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: '#000CFF', - 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, - }, - store, - stubs: { - GlLabel: true, - }, - provide: { - groupId: null, - rootPath: '/', - }, - }); - }); - - 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('.confidential-icon').exists()).toBe(false); - }); - - it('does not render blocked icon', () => { - expect(wrapper.find('.issue-blocked-icon').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], - updateData(newData) { - Object.assign(this, newData); - }, - }, - }); - - 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); - }); - - it('renders the avatar using avatar_url property', (done) => { - wrapper.props('issue').updateData({ - ...wrapper.props('issue'), - assignees: [ - { - id: '1', - name: 'test', - state: 'active', - username: 'test_name', - avatar_url: 'test_image_from_avatar_url', - }, - ], - }); - - wrapper.vm.$nextTick(() => { - expect(wrapper.find('.board-card-assignee img').attributes('src')).toBe( - 'test_image_from_avatar_url?width=24', - ); - done(); - }); - }); - }); - - describe('assignee default avatar', () => { - beforeEach((done) => { - global.gon.default_avatar_url = 'default_avatar'; - - wrapper.setProps({ - issue: { - ...wrapper.props('issue'), - assignees: [ - new ListAssignee({ - id: 1, - name: 'testing 123', - username: 'test', - }), - ], - }, - }); - - wrapper.vm.$nextTick(done); - }); - - afterEach(() => { - global.gon.default_avatar_url = null; - }); - - 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(GlLabel).length).toBe(1); - const label = wrapper.find(GlLabel); - expect(label.props('title')).toEqual(label1.title); - expect(label.props('description')).toEqual(label1.description); - expect(label.props('backgroundColor')).toEqual(label1.color); - }); - - 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(GlLabel).length).toBe(1); - expect(wrapper.text()).not.toContain('closed'); - done(); - }) - .catch(done.fail); - }); - }); - - describe('blocked', () => { - beforeEach((done) => { - wrapper.setProps({ - issue: { - ...wrapper.props('issue'), - blocked: true, - }, - }); - wrapper.vm.$nextTick(done); - }); - - it('renders blocked icon if issue is blocked', () => { - expect(wrapper.find('.issue-blocked-icon').exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/boards/issue_spec.js b/spec/frontend/boards/issue_spec.js deleted file mode 100644 index 1f354fb04db..00000000000 --- a/spec/frontend/boards/issue_spec.js +++ /dev/null @@ -1,162 +0,0 @@ -/* global ListIssue */ - -import '~/boards/models/label'; -import '~/boards/models/assignee'; -import '~/boards/models/issue'; -import '~/boards/models/list'; -import boardsStore from '~/boards/stores/boards_store'; -import { setMockEndpoints, mockIssue } from './mock_data'; - -describe('Issue model', () => { - let issue; - - beforeEach(() => { - setMockEndpoints(); - boardsStore.create(); - - issue = new ListIssue(mockIssue); - }); - - it('has label', () => { - expect(issue.labels.length).toBe(1); - }); - - it('add new label', () => { - issue.addLabel({ - id: 2, - title: 'bug', - color: 'blue', - description: 'bugs!', - }); - - expect(issue.labels.length).toBe(2); - }); - - it('does not add label if label id exists', () => { - issue.addLabel({ - id: 1, - title: 'test 2', - color: 'blue', - description: 'testing', - }); - - expect(issue.labels.length).toBe(1); - expect(issue.labels[0].color).toBe('#F0AD4E'); - }); - - it('adds other label with same title', () => { - issue.addLabel({ - id: 2, - title: 'test', - color: 'blue', - description: 'other test', - }); - - expect(issue.labels.length).toBe(2); - }); - - it('finds label', () => { - const label = issue.findLabel(issue.labels[0]); - - expect(label).toBeDefined(); - }); - - it('removes label', () => { - const label = issue.findLabel(issue.labels[0]); - issue.removeLabel(label); - - expect(issue.labels.length).toBe(0); - }); - - it('removes multiple labels', () => { - issue.addLabel({ - id: 2, - title: 'bug', - color: 'blue', - description: 'bugs!', - }); - - expect(issue.labels.length).toBe(2); - - issue.removeLabels([issue.labels[0], issue.labels[1]]); - - expect(issue.labels.length).toBe(0); - }); - - it('adds assignee', () => { - issue.addAssignee({ - id: 2, - name: 'Bruce Wayne', - username: 'batman', - avatar_url: 'http://batman', - }); - - expect(issue.assignees.length).toBe(2); - }); - - it('finds assignee', () => { - const assignee = issue.findAssignee(issue.assignees[0]); - - expect(assignee).toBeDefined(); - }); - - it('removes assignee', () => { - const assignee = issue.findAssignee(issue.assignees[0]); - issue.removeAssignee(assignee); - - expect(issue.assignees.length).toBe(0); - }); - - it('removes all assignees', () => { - issue.removeAllAssignees(); - - expect(issue.assignees.length).toBe(0); - }); - - it('sets position to infinity if no position is stored', () => { - expect(issue.position).toBe(Infinity); - }); - - it('sets position', () => { - const relativePositionIssue = new ListIssue({ - title: 'Testing', - iid: 1, - confidential: false, - relative_position: 1, - labels: [], - assignees: [], - }); - - expect(relativePositionIssue.position).toBe(1); - }); - - it('updates data', () => { - issue.updateData({ subscribed: true }); - - expect(issue.subscribed).toBe(true); - }); - - it('sets fetching state', () => { - expect(issue.isFetching.subscriptions).toBe(true); - - issue.setFetchingState('subscriptions', false); - - expect(issue.isFetching.subscriptions).toBe(false); - }); - - it('sets loading state', () => { - issue.setLoadingState('foo', true); - - expect(issue.isLoading.foo).toBe(true); - }); - - describe('update', () => { - it('passes update to boardsStore', () => { - jest.spyOn(boardsStore, 'updateIssue').mockImplementation(); - - issue.update(); - - expect(boardsStore.updateIssue).toHaveBeenCalledWith(issue); - }); - }); -}); diff --git a/spec/frontend/boards/list_spec.js b/spec/frontend/boards/list_spec.js deleted file mode 100644 index 4d6a82bdff0..00000000000 --- a/spec/frontend/boards/list_spec.js +++ /dev/null @@ -1,230 +0,0 @@ -/* global List */ -/* global ListAssignee */ -/* global ListIssue */ -/* global ListLabel */ -import MockAdapter from 'axios-mock-adapter'; -import waitForPromises from 'helpers/wait_for_promises'; -import '~/boards/models/label'; -import '~/boards/models/assignee'; -import '~/boards/models/issue'; -import '~/boards/models/list'; -import { ListType } from '~/boards/constants'; -import boardsStore from '~/boards/stores/boards_store'; -import axios from '~/lib/utils/axios_utils'; -import { listObj, listObjDuplicate, boardsMockInterceptor } from './mock_data'; - -describe('List model', () => { - let list; - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - mock.onAny().reply(boardsMockInterceptor); - boardsStore.create(); - boardsStore.setEndpoints({ - listsEndpoint: '/test/-/boards/1/lists', - }); - - list = new List(listObj); - return waitForPromises(); - }); - - afterEach(() => { - mock.restore(); - }); - - describe('list type', () => { - const notExpandableList = ['blank']; - - const table = Object.keys(ListType).map((k) => { - const value = ListType[k]; - return [value, !notExpandableList.includes(value)]; - }); - it.each(table)(`when list_type is %s boards isExpandable is %p`, (type, result) => { - expect(new List({ id: 1, list_type: type }).isExpandable).toBe(result); - }); - }); - - it('gets issues when created', () => { - expect(list.issues.length).toBe(1); - }); - - it('saves list and returns ID', () => { - list = new List({ - title: 'test', - label: { - id: 1, - title: 'test', - color: '#ff0000', - text_color: 'white', - }, - }); - return list.save().then(() => { - expect(list.id).toBe(listObj.id); - expect(list.type).toBe('label'); - expect(list.position).toBe(0); - expect(list.label).toEqual(listObj.label); - }); - }); - - it('destroys the list', () => { - boardsStore.addList(listObj); - list = boardsStore.findList('id', listObj.id); - - expect(boardsStore.state.lists.length).toBe(1); - list.destroy(); - - return waitForPromises().then(() => { - expect(boardsStore.state.lists.length).toBe(0); - }); - }); - - it('gets issue from list', () => { - const issue = list.findIssue(1); - - expect(issue).toBeDefined(); - }); - - it('removes issue', () => { - const issue = list.findIssue(1); - - expect(list.issues.length).toBe(1); - list.removeIssue(issue); - - expect(list.issues.length).toBe(0); - }); - - it('sends service request to update issue label', () => { - const listDup = new List(listObjDuplicate); - const issue = new ListIssue({ - title: 'Testing', - id: 1, - iid: 1, - confidential: false, - labels: [list.label, listDup.label], - assignees: [], - }); - - list.issues.push(issue); - listDup.issues.push(issue); - - jest.spyOn(boardsStore, 'moveIssue'); - - listDup.updateIssueLabel(issue, list); - - expect(boardsStore.moveIssue).toHaveBeenCalledWith( - issue.id, - list.id, - listDup.id, - undefined, - undefined, - ); - }); - - describe('page number', () => { - beforeEach(() => { - jest.spyOn(list, 'getIssues').mockImplementation(() => {}); - list.issues = []; - }); - - it('increase page number if current issue count is more than the page size', () => { - for (let i = 0; i < 30; i += 1) { - list.issues.push( - new ListIssue({ - title: 'Testing', - id: i, - iid: i, - confidential: false, - labels: [list.label], - assignees: [], - }), - ); - } - list.issuesSize = 50; - - expect(list.issues.length).toBe(30); - - list.nextPage(); - - expect(list.page).toBe(2); - expect(list.getIssues).toHaveBeenCalled(); - }); - - it('does not increase page number if issue count is less than the page size', () => { - list.issues.push( - new ListIssue({ - title: 'Testing', - id: 1, - confidential: false, - labels: [list.label], - assignees: [], - }), - ); - list.issuesSize = 2; - - list.nextPage(); - - expect(list.page).toBe(1); - expect(list.getIssues).toHaveBeenCalled(); - }); - }); - - describe('newIssue', () => { - beforeEach(() => { - jest.spyOn(boardsStore, 'newIssue').mockReturnValue( - Promise.resolve({ - data: { - id: 42, - subscribed: false, - assignable_labels_endpoint: '/issue/42/labels', - toggle_subscription_endpoint: '/issue/42/subscriptions', - issue_sidebar_endpoint: '/issue/42/sidebar_info', - }, - }), - ); - list.issues = []; - }); - - it('adds new issue to top of list', (done) => { - const user = new ListAssignee({ - id: 1, - name: 'testing 123', - username: 'test', - avatar: 'test_image', - }); - - list.issues.push( - new ListIssue({ - title: 'Testing', - id: 1, - confidential: false, - labels: [new ListLabel(list.label)], - assignees: [], - }), - ); - const dummyIssue = new ListIssue({ - title: 'new issue', - id: 2, - confidential: false, - labels: [new ListLabel(list.label)], - assignees: [user], - subscribed: false, - }); - - list - .newIssue(dummyIssue) - .then(() => { - expect(list.issues.length).toBe(2); - expect(list.issues[0]).toBe(dummyIssue); - expect(list.issues[0].subscribed).toBe(false); - expect(list.issues[0].assignableLabelsEndpoint).toBe('/issue/42/labels'); - expect(list.issues[0].toggleSubscriptionEndpoint).toBe('/issue/42/subscriptions'); - expect(list.issues[0].sidebarInfoEndpoint).toBe('/issue/42/sidebar_info'); - expect(list.issues[0].labels).toBe(dummyIssue.labels); - expect(list.issues[0].assignees).toBe(dummyIssue.assignees); - }) - .then(done) - .catch(done.fail); - }); - }); -}); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 106f7b04c4b..6a4f344bbfb 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -1,12 +1,8 @@ -/* global List */ - import { GlFilteredSearchToken } from '@gitlab/ui'; import { keyBy } from 'lodash'; -import Vue from 'vue'; -import '~/boards/models/list'; import { ListType } from '~/boards/constants'; -import boardsStore from '~/boards/stores/boards_store'; import { __ } from '~/locale'; +import { DEFAULT_MILESTONES_GRAPHQL } from '~/vue_shared/components/filtered_search_bar/constants'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; @@ -196,8 +192,7 @@ export const mockIssue = { export const mockActiveIssue = { ...mockIssue, - fullId: 'gid://gitlab/Issue/436', - id: 436, + id: 'gid://gitlab/Issue/436', iid: '27', subscribed: false, emailsDisabled: false, @@ -289,20 +284,6 @@ export const boardsMockInterceptor = (config) => { return [200, body]; }; -export const setMockEndpoints = (opts = {}) => { - const boardsEndpoint = opts.boardsEndpoint || '/test/issue-boards/-/boards.json'; - const listsEndpoint = opts.listsEndpoint || '/test/-/boards/1/lists'; - const bulkUpdatePath = opts.bulkUpdatePath || ''; - const boardId = opts.boardId || '1'; - - boardsStore.setEndpoints({ - boardsEndpoint, - listsEndpoint, - bulkUpdatePath, - boardId, - }); -}; - export const mockList = { id: 'gid://gitlab/List/1', title: 'Open', @@ -335,14 +316,26 @@ export const mockLabelList = { issuesCount: 0, }; +export const mockMilestoneList = { + id: 'gid://gitlab/List/3', + title: 'To Do', + position: 0, + listType: 'milestone', + collapsed: false, + label: null, + assignee: null, + milestone: { + webUrl: 'https://gitlab.com/h5bp/html5-boilerplate/-/milestones/1', + title: 'Backlog', + }, + loading: false, + issuesCount: 0, +}; + export const mockLists = [mockList, mockLabelList]; export const mockListsById = keyBy(mockLists, 'id'); -export const mockListsWithModel = mockLists.map((listMock) => - Vue.observable(new List({ ...listMock, doNotFetchIssues: true })), -); - export const mockIssuesByListId = { 'gid://gitlab/List/1': [mockIssue.id, mockIssue3.id, mockIssue4.id], 'gid://gitlab/List/2': mockIssues.map(({ id }) => id), @@ -547,17 +540,17 @@ export const mockMoveData = { export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones) => [ { - icon: 'labels', - title: __('Label'), - type: 'label_name', + icon: 'user', + title: __('Assignee'), + type: 'assignee_username', operators: [ { value: '=', description: 'is' }, { value: '!=', description: 'is not' }, ], - token: LabelToken, - unique: false, - symbol: '~', - fetchLabels, + token: AuthorToken, + unique: true, + fetchAuthors, + preloadedAuthors: [], }, { icon: 'pencil', @@ -574,17 +567,27 @@ export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones) => [ preloadedAuthors: [], }, { - icon: 'user', - title: __('Assignee'), - type: 'assignee_username', + icon: 'labels', + title: __('Label'), + type: 'label_name', operators: [ { value: '=', description: 'is' }, { value: '!=', description: 'is not' }, ], - token: AuthorToken, + token: LabelToken, + unique: false, + symbol: '~', + fetchLabels, + }, + { + icon: 'clock', + title: __('Milestone'), + symbol: '%', + type: 'milestone_title', + token: MilestoneToken, unique: true, - fetchAuthors, - preloadedAuthors: [], + defaultMilestones: DEFAULT_MILESTONES_GRAPHQL, + fetchMilestones, }, { icon: 'issues', @@ -599,16 +602,6 @@ export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones) => [ ], }, { - icon: 'clock', - title: __('Milestone'), - symbol: '%', - type: 'milestone_title', - token: MilestoneToken, - unique: true, - defaultMilestones: [], - fetchMilestones, - }, - { icon: 'weight', title: __('Weight'), type: 'weight', diff --git a/spec/frontend/boards/project_select_deprecated_spec.js b/spec/frontend/boards/project_select_deprecated_spec.js deleted file mode 100644 index 4494de43083..00000000000 --- a/spec/frontend/boards/project_select_deprecated_spec.js +++ /dev/null @@ -1,263 +0,0 @@ -import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import axios from 'axios'; -import AxiosMockAdapter from 'axios-mock-adapter'; -import ProjectSelect from '~/boards/components/project_select_deprecated.vue'; -import { ListType } from '~/boards/constants'; -import eventHub from '~/boards/eventhub'; -import createFlash from '~/flash'; -import httpStatus from '~/lib/utils/http_status'; -import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; - -import { listObj, mockRawGroupProjects } from './mock_data'; - -jest.mock('~/boards/eventhub'); -jest.mock('~/flash'); - -const dummyGon = { - api_version: 'v4', - relative_url_root: '/gitlab', -}; - -const mockGroupId = 1; -const mockProjectsList1 = mockRawGroupProjects.slice(0, 1); -const mockProjectsList2 = mockRawGroupProjects.slice(1); -const mockDefaultFetchOptions = { - with_issues_enabled: true, - with_shared: false, - include_subgroups: true, - order_by: 'similarity', - archived: false, -}; - -const itemsPerPage = 20; - -describe('ProjectSelect component', () => { - let wrapper; - let axiosMock; - - const findLabel = () => wrapper.find("[data-testid='header-label']"); - const findGlDropdown = () => wrapper.find(GlDropdown); - const findGlDropdownLoadingIcon = () => - findGlDropdown().find('button:first-child').find(GlLoadingIcon); - const findGlSearchBoxByType = () => wrapper.find(GlSearchBoxByType); - const findGlDropdownItems = () => wrapper.findAll(GlDropdownItem); - const findFirstGlDropdownItem = () => findGlDropdownItems().at(0); - const findInMenuLoadingIcon = () => wrapper.find("[data-testid='dropdown-text-loading-icon']"); - const findEmptySearchMessage = () => wrapper.find("[data-testid='empty-result-message']"); - - const mockGetRequest = (data = [], statusCode = httpStatus.OK) => { - axiosMock - .onGet(`/gitlab/api/v4/groups/${mockGroupId}/projects.json`) - .replyOnce(statusCode, data); - }; - - const searchForProject = async (keyword, waitForAll = true) => { - findGlSearchBoxByType().vm.$emit('input', keyword); - - if (waitForAll) { - await axios.waitForAll(); - } - }; - - const createWrapper = async ({ list = listObj } = {}, waitForAll = true) => { - wrapper = mount(ProjectSelect, { - propsData: { - list, - }, - provide: { - groupId: 1, - }, - }); - - if (waitForAll) { - await axios.waitForAll(); - } - }; - - beforeEach(() => { - axiosMock = new AxiosMockAdapter(axios); - window.gon = dummyGon; - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - axiosMock.restore(); - jest.clearAllMocks(); - }); - - it('displays a header title', async () => { - createWrapper({}); - - expect(findLabel().text()).toBe('Projects'); - }); - - it('renders a default dropdown text', async () => { - createWrapper({}); - - expect(findGlDropdown().exists()).toBe(true); - expect(findGlDropdown().text()).toContain('Select a project'); - }); - - describe('when mounted', () => { - it('displays a loading icon while projects are being fetched', async () => { - mockGetRequest([]); - - createWrapper({}, false); - - expect(findGlDropdownLoadingIcon().exists()).toBe(true); - - await axios.waitForAll(); - - expect(axiosMock.history.get[0].params).toMatchObject({ search: '' }); - expect(axiosMock.history.get[0].url).toBe( - `/gitlab/api/v4/groups/${mockGroupId}/projects.json`, - ); - - expect(findGlDropdownLoadingIcon().exists()).toBe(false); - }); - }); - - describe('when dropdown menu is open', () => { - describe('by default', () => { - beforeEach(async () => { - mockGetRequest(mockProjectsList1); - - await createWrapper(); - }); - - it('shows GlSearchBoxByType with default attributes', () => { - expect(findGlSearchBoxByType().exists()).toBe(true); - expect(findGlSearchBoxByType().vm.$attrs).toMatchObject({ - placeholder: 'Search projects', - debounce: '250', - }); - }); - - it("displays the fetched project's name", () => { - expect(findFirstGlDropdownItem().exists()).toBe(true); - expect(findFirstGlDropdownItem().text()).toContain(mockProjectsList1[0].name); - }); - - it("doesn't render loading icon in the menu", () => { - expect(findInMenuLoadingIcon().isVisible()).toBe(false); - }); - - it('renders empty search result message', async () => { - await createWrapper(); - - expect(findEmptySearchMessage().exists()).toBe(true); - }); - }); - - describe('when a project is selected', () => { - beforeEach(async () => { - mockGetRequest(mockProjectsList1); - - await createWrapper(); - - await findFirstGlDropdownItem().find('button').trigger('click'); - }); - - it('emits setSelectedProject with correct project metadata', () => { - expect(eventHub.$emit).toHaveBeenCalledWith('setSelectedProject', { - id: mockProjectsList1[0].id, - path: mockProjectsList1[0].path_with_namespace, - name: mockProjectsList1[0].name, - namespacedName: mockProjectsList1[0].name_with_namespace, - }); - }); - - it('renders the name of the selected project', () => { - expect(findGlDropdown().find('.gl-new-dropdown-button-text').text()).toBe( - mockProjectsList1[0].name, - ); - }); - }); - - describe('when user searches for a project', () => { - beforeEach(async () => { - mockGetRequest(mockProjectsList1); - - await createWrapper(); - }); - - it('calls API with correct parameters with default fetch options', async () => { - await searchForProject('foobar'); - - const expectedApiParams = { - search: 'foobar', - per_page: itemsPerPage, - ...mockDefaultFetchOptions, - }; - - expect(axiosMock.history.get[1].params).toMatchObject(expectedApiParams); - expect(axiosMock.history.get[1].url).toBe( - `/gitlab/api/v4/groups/${mockGroupId}/projects.json`, - ); - }); - - describe("when list type is defined and isn't backlog", () => { - it('calls API with an additional fetch option (min_access_level)', async () => { - axiosMock.reset(); - - await createWrapper({ list: { ...listObj, type: ListType.label } }); - - await searchForProject('foobar'); - - const expectedApiParams = { - search: 'foobar', - per_page: itemsPerPage, - ...mockDefaultFetchOptions, - min_access_level: featureAccessLevel.EVERYONE, - }; - - expect(axiosMock.history.get[1].params).toMatchObject(expectedApiParams); - expect(axiosMock.history.get[1].url).toBe( - `/gitlab/api/v4/groups/${mockGroupId}/projects.json`, - ); - }); - }); - - it('displays and hides gl-loading-icon while and after fetching data', async () => { - await searchForProject('some keyword', false); - - await wrapper.vm.$nextTick(); - - expect(findInMenuLoadingIcon().isVisible()).toBe(true); - - await axios.waitForAll(); - - expect(findInMenuLoadingIcon().isVisible()).toBe(false); - }); - - it('flashes an error message when fetching fails', async () => { - mockGetRequest([], httpStatus.INTERNAL_SERVER_ERROR); - - await searchForProject('foobar'); - - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ - message: 'Something went wrong while fetching projects', - }); - }); - - describe('with non-empty search result', () => { - beforeEach(async () => { - mockGetRequest(mockProjectsList2); - - await searchForProject('foobar'); - }); - - it('displays the retrieved list of projects', async () => { - expect(findFirstGlDropdownItem().text()).toContain(mockProjectsList2[0].name); - }); - - it('does not render empty search result message', async () => { - expect(findEmptySearchMessage().exists()).toBe(false); - }); - }); - }); - }); -}); diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 1272a573d2f..62e0fa7a68a 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -26,7 +26,6 @@ import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql' import actions, { gqlClient } from '~/boards/stores/actions'; import * as types from '~/boards/stores/mutation_types'; import mutations from '~/boards/stores/mutations'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { mockLists, @@ -107,12 +106,7 @@ describe('setFilters', () => { }); describe('performSearch', () => { - it('should dispatch setFilters action', (done) => { - testAction(actions.performSearch, {}, {}, [], [{ type: 'setFilters', payload: {} }], done); - }); - - it('should dispatch setFilters, fetchLists and resetIssues action when graphqlBoardLists FF is on', (done) => { - window.gon = { features: { graphqlBoardLists: true } }; + it('should dispatch setFilters, fetchLists and resetIssues action', (done) => { testAction( actions.performSearch, {}, @@ -496,12 +490,9 @@ describe('fetchLabels', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); const commit = jest.fn(); - const getters = { - shouldUseGraphQL: () => true, - }; const state = { boardType: 'group' }; - await actions.fetchLabels({ getters, state, commit }); + await actions.fetchLabels({ state, commit }); expect(commit).toHaveBeenCalledWith(types.RECEIVE_LABELS_SUCCESS, labels); }); @@ -954,7 +945,7 @@ describe('moveIssue', () => { }); describe('moveIssueCard and undoMoveIssueCard', () => { - describe('card should move without clonning', () => { + describe('card should move without cloning', () => { let state; let params; let moveMutations; @@ -1221,8 +1212,8 @@ describe('updateMovedIssueCard', () => { describe('updateIssueOrder', () => { const issues = { - 436: mockIssue, - 437: mockIssue2, + [mockIssue.id]: mockIssue, + [mockIssue2.id]: mockIssue2, }; const state = { @@ -1231,7 +1222,7 @@ describe('updateIssueOrder', () => { }; const moveData = { - itemId: 436, + itemId: mockIssue.id, fromListId: 'gid://gitlab/List/1', toListId: 'gid://gitlab/List/2', }; @@ -1490,7 +1481,7 @@ describe('addListNewIssue', () => { type: 'addListItem', payload: { list: fakeList, - item: formatIssue({ ...mockIssue, id: getIdFromGraphQLId(mockIssue.id) }), + item: formatIssue(mockIssue), position: 0, }, }, diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js index c0774dd3ae1..b30968c45d7 100644 --- a/spec/frontend/boards/stores/getters_spec.js +++ b/spec/frontend/boards/stores/getters_spec.js @@ -77,12 +77,12 @@ describe('Boards - Getters', () => { }); describe('getBoardItemById', () => { - const state = { boardItems: { 1: 'issue' } }; + const state = { boardItems: { 'gid://gitlab/Issue/1': 'issue' } }; it.each` - id | expected - ${'1'} | ${'issue'} - ${''} | ${{}} + id | expected + ${'gid://gitlab/Issue/1'} | ${'issue'} + ${''} | ${{}} `('returns $expected when $id is passed to state', ({ id, expected }) => { expect(getters.getBoardItemById(state)(id)).toEqual(expected); }); @@ -90,11 +90,11 @@ describe('Boards - Getters', () => { describe('activeBoardItem', () => { it.each` - id | expected - ${'1'} | ${'issue'} - ${''} | ${{ id: '', iid: '', fullId: '' }} + id | expected + ${'gid://gitlab/Issue/1'} | ${'issue'} + ${''} | ${{ id: '', iid: '' }} `('returns $expected when $id is passed to state', ({ id, expected }) => { - const state = { boardItems: { 1: 'issue' }, activeId: id }; + const state = { boardItems: { 'gid://gitlab/Issue/1': 'issue' }, activeId: id }; expect(getters.activeBoardItem(state)).toEqual(expected); }); diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index a2ba1e9eb5e..0e830258327 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -407,7 +407,7 @@ describe('Board Store Mutations', () => { describe('MUTATE_ISSUE_SUCCESS', () => { it('updates issue in issues state', () => { const issues = { - 436: { id: rawIssue.id }, + [rawIssue.id]: { id: rawIssue.id }, }; state = { @@ -419,7 +419,7 @@ describe('Board Store Mutations', () => { issue: rawIssue, }); - expect(state.boardItems).toEqual({ 436: { ...mockIssue, id: 436 } }); + expect(state.boardItems).toEqual({ [mockIssue.id]: mockIssue }); }); }); @@ -545,7 +545,7 @@ describe('Board Store Mutations', () => { expect(state.groupProjectsFlags.isLoading).toBe(true); }); - it('Should set isLoading in groupProjectsFlags to true in state when fetchNext is true', () => { + it('Should set isLoadingMore in groupProjectsFlags to true in state when fetchNext is true', () => { mutations[types.REQUEST_GROUP_PROJECTS](state, true); expect(state.groupProjectsFlags.isLoadingMore).toBe(true); diff --git a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap index 0e1fe790771..b34265b7234 100644 --- a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap +++ b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap @@ -47,8 +47,26 @@ exports[`Remove cluster confirmation modal renders splitbutton with modal includ <!----> <div + class="gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-align-items-center gl-px-5" + > + <div + class="gl-display-flex" + > + <!----> + </div> + + <div + class="gl-display-flex" + > + <!----> + </div> + </div> + + <div class="gl-new-dropdown-contents" > + <!----> + <li class="gl-new-dropdown-item" role="presentation" 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 271c6356f7e..c2fa6556847 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 @@ -17,11 +17,15 @@ exports[`Confidential merge request project form group component renders empty s No forks are available to you. <br /> - - <gl-sprintf-stub - message="To protect this issue's confidentiality, %{forkLink} and set the fork's visibility to private." - /> - + To protect this issue's confidentiality, + <a + class="help-link" + href="https://test.com" + target="_blank" + > + fork this project + </a> + and set the fork's visibility to private. <gl-link-stub class="w-auto p-0 d-inline-block text-primary bg-transparent" href="/help" @@ -52,18 +56,16 @@ exports[`Confidential merge request project form group component renders fork dr </label> <div> - <!----> + <dropdown-stub + projects="[object Object],[object Object]" + selectedproject="[object Object]" + /> <p class="text-muted mt-1 mb-0" > - No forks are available to you. - <br /> - - <gl-sprintf-stub - message="To protect this issue's confidentiality, %{forkLink} and set the fork's visibility to private." - /> + To protect this issue's confidentiality, a private fork of this project was selected. <gl-link-stub class="w-auto p-0 d-inline-block text-primary bg-transparent" diff --git a/spec/frontend/confidential_merge_request/components/project_form_group_spec.js b/spec/frontend/confidential_merge_request/components/project_form_group_spec.js index 67f6d360f52..0e73d50fdb5 100644 --- a/spec/frontend/confidential_merge_request/components/project_form_group_spec.js +++ b/spec/frontend/confidential_merge_request/components/project_form_group_spec.js @@ -1,3 +1,4 @@ +import { GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import ProjectFormGroup from '~/confidential_merge_request/components/project_form_group.vue'; @@ -21,55 +22,52 @@ const mockData = [ }, }, ]; -let vm; +let wrapper; let mock; function factory(projects = mockData) { mock = new MockAdapter(axios); mock.onGet(/api\/(.*)\/projects\/gitlab-org%2Fgitlab-ce\/forks/).reply(200, projects); - vm = shallowMount(ProjectFormGroup, { + wrapper = shallowMount(ProjectFormGroup, { propsData: { namespacePath: 'gitlab-org', projectPath: 'gitlab-org/gitlab-ce', newForkPath: 'https://test.com', helpPagePath: '/help', }, + stubs: { GlSprintf }, }); + + return axios.waitForAll(); } describe('Confidential merge request project form group component', () => { afterEach(() => { mock.restore(); - vm.destroy(); + wrapper.destroy(); }); - it('renders fork dropdown', () => { - factory(); + it('renders fork dropdown', async () => { + await factory(); - return vm.vm.$nextTick(() => { - expect(vm.element).toMatchSnapshot(); - }); + expect(wrapper.element).toMatchSnapshot(); }); - it('sets selected project as first fork', () => { - factory(); + it('sets selected project as first fork', async () => { + await factory(); - return vm.vm.$nextTick(() => { - expect(vm.vm.selectedProject).toEqual({ - id: 1, - name: 'root / gitlab-ce', - pathWithNamespace: 'root/gitlab-ce', - namespaceFullpath: 'root', - }); + expect(wrapper.vm.selectedProject).toEqual({ + id: 1, + name: 'root / gitlab-ce', + pathWithNamespace: 'root/gitlab-ce', + namespaceFullpath: 'root', }); }); - it('renders empty state when response is empty', () => { - factory([]); + it('renders empty state when response is empty', async () => { + await factory([]); - return vm.vm.$nextTick(() => { - expect(vm.element).toMatchSnapshot(); - }); + expect(wrapper.element).toMatchSnapshot(); }); }); diff --git a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap index 3c88c05a4b4..8f5516545eb 100644 --- a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap +++ b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap @@ -11,7 +11,16 @@ exports[`content_editor/components/toolbar_link_button renders dropdown componen <ul role=\\"menu\\" tabindex=\\"-1\\" class=\\"dropdown-menu\\"> <div class=\\"gl-new-dropdown-inner\\"> <!----> + <div class=\\"gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-align-items-center gl-px-5\\"> + <div class=\\"gl-display-flex\\"> + <!----> + </div> + <div class=\\"gl-display-flex\\"> + <!----> + </div> + </div> <div class=\\"gl-new-dropdown-contents\\"> + <!----> <li role=\\"presentation\\" class=\\"gl-px-3!\\"> <form tabindex=\\"-1\\" class=\\"b-dropdown-form gl-p-0\\"> <div placeholder=\\"Link URL\\"> diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index d516baf6f0f..3d1ef03083d 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -6,6 +6,7 @@ import ContentEditor from '~/content_editor/components/content_editor.vue'; import ContentEditorError from '~/content_editor/components/content_editor_error.vue'; import ContentEditorProvider from '~/content_editor/components/content_editor_provider.vue'; import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; +import FormattingBubbleMenu from '~/content_editor/components/formatting_bubble_menu.vue'; import TopToolbar from '~/content_editor/components/top_toolbar.vue'; import { LOADING_CONTENT_EVENT, @@ -25,6 +26,7 @@ describe('ContentEditor', () => { const findEditorElement = () => wrapper.findByTestId('content-editor'); const findEditorContent = () => wrapper.findComponent(EditorContent); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findBubbleMenu = () => wrapper.findComponent(FormattingBubbleMenu); const createWrapper = (propsData = {}) => { renderMarkdown = jest.fn(); @@ -131,6 +133,10 @@ describe('ContentEditor', () => { it('hides EditorContent component', () => { expect(findEditorContent().exists()).toBe(false); }); + + it('hides formatting bubble menu', () => { + expect(findBubbleMenu().exists()).toBe(false); + }); }); describe('when loading content succeeds', () => { @@ -171,5 +177,9 @@ describe('ContentEditor', () => { it('displays EditorContent component', () => { expect(findEditorContent().exists()).toBe(true); }); + + it('displays formatting bubble menu', () => { + expect(findBubbleMenu().exists()).toBe(true); + }); }); }); diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js new file mode 100644 index 00000000000..e48f59f6d9c --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js @@ -0,0 +1,193 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { NodeViewWrapper } from '@tiptap/vue-2'; +import { selectedRect as getSelectedRect } from 'prosemirror-tables'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import TableCellBaseWrapper from '~/content_editor/components/wrappers/table_cell_base.vue'; +import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../../test_utils'; + +jest.mock('prosemirror-tables'); + +describe('content/components/wrappers/table_cell_base', () => { + let wrapper; + let editor; + let getPos; + + const createWrapper = async (propsData = { cellType: 'td' }) => { + wrapper = shallowMountExtended(TableCellBaseWrapper, { + propsData: { + editor, + getPos, + ...propsData, + }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownItemWithLabel = (name) => + wrapper + .findAllComponents(GlDropdownItem) + .filter((dropdownItem) => dropdownItem.text().includes(name)) + .at(0); + const findDropdownItemWithLabelExists = (name) => + wrapper + .findAllComponents(GlDropdownItem) + .filter((dropdownItem) => dropdownItem.text().includes(name)).length > 0; + const setCurrentPositionInCell = () => { + const { $cursor } = editor.state.selection; + + getPos.mockReturnValue($cursor.pos - $cursor.parentOffset - 1); + }; + const mockDropdownHide = () => { + /* + * TODO: Replace this method with using the scoped hide function + * provided by BootstrapVue https://bootstrap-vue.org/docs/components/dropdown. + * GitLab UI is not exposing it in the default scope + */ + findDropdown().vm.hide = jest.fn(); + }; + + beforeEach(() => { + getPos = jest.fn(); + editor = createTestEditor({}); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a td node-view-wrapper with relative position', () => { + createWrapper(); + expect(wrapper.findComponent(NodeViewWrapper).classes()).toContain('gl-relative'); + expect(wrapper.findComponent(NodeViewWrapper).props().as).toBe('td'); + }); + + it('displays dropdown when selection cursor is on the cell', async () => { + setCurrentPositionInCell(); + createWrapper(); + + await nextTick(); + + expect(findDropdown().props()).toMatchObject({ + category: 'tertiary', + icon: 'chevron-down', + size: 'small', + split: false, + }); + expect(findDropdown().attributes()).toMatchObject({ + boundary: 'viewport', + 'no-caret': '', + }); + }); + + it('does not display dropdown when selection cursor is not on the cell', async () => { + createWrapper(); + + await nextTick(); + + expect(findDropdown().exists()).toBe(false); + }); + + describe('when dropdown is visible', () => { + beforeEach(async () => { + setCurrentPositionInCell(); + getSelectedRect.mockReturnValue({ + map: { + height: 1, + width: 1, + }, + }); + + createWrapper(); + await nextTick(); + + mockDropdownHide(); + }); + + it.each` + dropdownItemLabel | commandName + ${'Insert column before'} | ${'addColumnBefore'} + ${'Insert column after'} | ${'addColumnAfter'} + ${'Insert row before'} | ${'addRowBefore'} + ${'Insert row after'} | ${'addRowAfter'} + ${'Delete table'} | ${'deleteTable'} + `( + 'executes $commandName when $dropdownItemLabel button is clicked', + ({ commandName, dropdownItemLabel }) => { + const mocks = mockChainedCommands(editor, [commandName, 'run']); + + findDropdownItemWithLabel(dropdownItemLabel).vm.$emit('click'); + + expect(mocks[commandName]).toHaveBeenCalled(); + }, + ); + + it('does not allow deleting rows and columns', async () => { + expect(findDropdownItemWithLabelExists('Delete row')).toBe(false); + expect(findDropdownItemWithLabelExists('Delete column')).toBe(false); + }); + + it('allows deleting rows when there are more than 2 rows in the table', async () => { + const mocks = mockChainedCommands(editor, ['deleteRow', 'run']); + + getSelectedRect.mockReturnValue({ + map: { + height: 3, + }, + }); + + emitEditorEvent({ tiptapEditor: editor, event: 'selectionUpdate' }); + + await nextTick(); + + findDropdownItemWithLabel('Delete row').vm.$emit('click'); + + expect(mocks.deleteRow).toHaveBeenCalled(); + }); + + it('allows deleting columns when there are more than 1 column in the table', async () => { + const mocks = mockChainedCommands(editor, ['deleteColumn', 'run']); + + getSelectedRect.mockReturnValue({ + map: { + width: 2, + }, + }); + + emitEditorEvent({ tiptapEditor: editor, event: 'selectionUpdate' }); + + await nextTick(); + + findDropdownItemWithLabel('Delete column').vm.$emit('click'); + + expect(mocks.deleteColumn).toHaveBeenCalled(); + }); + + describe('when current row is the table’s header', () => { + beforeEach(async () => { + // Remove 2 rows condition + getSelectedRect.mockReturnValue({ + map: { + height: 3, + }, + }); + + createWrapper({ cellType: 'th' }); + + await nextTick(); + }); + + it('does not allow adding a row before the header', async () => { + expect(findDropdownItemWithLabelExists('Insert row before')).toBe(false); + }); + + it('does not allow removing the header row', async () => { + createWrapper({ cellType: 'th' }); + + await nextTick(); + + expect(findDropdownItemWithLabelExists('Delete row')).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js new file mode 100644 index 00000000000..5d26c44ba03 --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js @@ -0,0 +1,37 @@ +import { shallowMount } from '@vue/test-utils'; +import TableCellBaseWrapper from '~/content_editor/components/wrappers/table_cell_base.vue'; +import TableCellBodyWrapper from '~/content_editor/components/wrappers/table_cell_body.vue'; +import { createTestEditor } from '../../test_utils'; + +describe('content/components/wrappers/table_cell_body', () => { + let wrapper; + let editor; + let getPos; + + const createWrapper = async () => { + wrapper = shallowMount(TableCellBodyWrapper, { + propsData: { + editor, + getPos, + }, + }); + }; + + beforeEach(() => { + getPos = jest.fn(); + editor = createTestEditor({}); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a TableCellBase component', () => { + createWrapper(); + expect(wrapper.findComponent(TableCellBaseWrapper).props()).toEqual({ + editor, + getPos, + cellType: 'td', + }); + }); +}); diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js new file mode 100644 index 00000000000..e561191418d --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js @@ -0,0 +1,37 @@ +import { shallowMount } from '@vue/test-utils'; +import TableCellBaseWrapper from '~/content_editor/components/wrappers/table_cell_base.vue'; +import TableCellHeaderWrapper from '~/content_editor/components/wrappers/table_cell_header.vue'; +import { createTestEditor } from '../../test_utils'; + +describe('content/components/wrappers/table_cell_header', () => { + let wrapper; + let editor; + let getPos; + + const createWrapper = async () => { + wrapper = shallowMount(TableCellHeaderWrapper, { + propsData: { + editor, + getPos, + }, + }); + }; + + beforeEach(() => { + getPos = jest.fn(); + editor = createTestEditor({}); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a TableCellBase component', () => { + createWrapper(); + expect(wrapper.findComponent(TableCellBaseWrapper).props()).toEqual({ + editor, + getPos, + cellType: 'th', + }); + }); +}); diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js index 1334b1ddaad..d4f05a25bd6 100644 --- a/spec/frontend/content_editor/extensions/attachment_spec.js +++ b/spec/frontend/content_editor/extensions/attachment_spec.js @@ -1,18 +1,23 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import { once } from 'lodash'; -import waitForPromises from 'helpers/wait_for_promises'; import Attachment from '~/content_editor/extensions/attachment'; import Image from '~/content_editor/extensions/image'; import Link from '~/content_editor/extensions/link'; import Loading from '~/content_editor/extensions/loading'; import httpStatus from '~/lib/utils/http_status'; -import { loadMarkdownApiResult } from '../markdown_processing_examples'; import { createTestEditor, createDocBuilder } from '../test_utils'; +const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `<p data-sourcepos="1:1-1:27" dir="auto"> + <a class="no-attachment-icon" href="/group1/project1/-/wikis/test-file.png" target="_blank" rel="noopener noreferrer" data-canonical-src="test-file.png"> + <img alt="test-file" class="lazy" data-src="/group1/project1/-/wikis/test-file.png" data-canonical-src="test-file.png"> + </a> +</p>`; +const PROJECT_WIKI_ATTACHMENT_LINK_HTML = `<p data-sourcepos="1:1-1:26" dir="auto"> + <a href="/group1/project1/-/wikis/test-file.zip" data-canonical-src="test-file.zip">test-file</a> +</p>`; + describe('content_editor/extensions/attachment', () => { let tiptapEditor; - let eq; let doc; let p; let image; @@ -25,6 +30,24 @@ describe('content_editor/extensions/attachment', () => { const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' }); const attachmentFile = new File(['foo'], 'test-file.zip', { type: 'application/zip' }); + const expectDocumentAfterTransaction = ({ number, expectedDoc, action }) => { + return new Promise((resolve) => { + let counter = 1; + const handleTransaction = () => { + if (counter === number) { + expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); + tiptapEditor.off('update', handleTransaction); + resolve(); + } + + counter += 1; + }; + + tiptapEditor.on('update', handleTransaction); + action(); + }); + }; + beforeEach(() => { renderMarkdown = jest.fn(); @@ -34,7 +57,6 @@ describe('content_editor/extensions/attachment', () => { ({ builders: { doc, p, image, loading, link }, - eq, } = createDocBuilder({ tiptapEditor, names: { @@ -76,9 +98,7 @@ describe('content_editor/extensions/attachment', () => { const base64EncodedFile = 'data:image/png;base64,Zm9v'; beforeEach(() => { - renderMarkdown.mockResolvedValue( - loadMarkdownApiResult('project_wiki_attachment_image').body, - ); + renderMarkdown.mockResolvedValue(PROJECT_WIKI_ATTACHMENT_IMAGE_HTML); }); describe('when uploading succeeds', () => { @@ -92,18 +112,14 @@ describe('content_editor/extensions/attachment', () => { mock.onPost().reply(httpStatus.OK, successResponse); }); - it('inserts an image with src set to the encoded image file and uploading true', (done) => { + it('inserts an image with src set to the encoded image file and uploading true', async () => { const expectedDoc = doc(p(image({ uploading: true, src: base64EncodedFile }))); - tiptapEditor.on( - 'update', - once(() => { - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); - done(); - }), - ); - - tiptapEditor.commands.uploadAttachment({ file: imageFile }); + await expectDocumentAfterTransaction({ + number: 1, + expectedDoc, + action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }), + }); }); it('updates the inserted image with canonicalSrc when upload is successful', async () => { @@ -118,11 +134,11 @@ describe('content_editor/extensions/attachment', () => { ), ); - tiptapEditor.commands.uploadAttachment({ file: imageFile }); - - await waitForPromises(); - - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + await expectDocumentAfterTransaction({ + number: 2, + expectedDoc, + action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }), + }); }); }); @@ -131,14 +147,14 @@ describe('content_editor/extensions/attachment', () => { mock.onPost().reply(httpStatus.INTERNAL_SERVER_ERROR); }); - it('resets the doc to orginal state', async () => { + it('resets the doc to original state', async () => { const expectedDoc = doc(p('')); - tiptapEditor.commands.uploadAttachment({ file: imageFile }); - - await waitForPromises(); - - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + await expectDocumentAfterTransaction({ + number: 2, + expectedDoc, + action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }), + }); }); it('emits an error event that includes an error message', (done) => { @@ -153,7 +169,7 @@ describe('content_editor/extensions/attachment', () => { }); describe('when the file has a zip (or any other attachment) mime type', () => { - const markdownApiResult = loadMarkdownApiResult('project_wiki_attachment_link').body; + const markdownApiResult = PROJECT_WIKI_ATTACHMENT_LINK_HTML; beforeEach(() => { renderMarkdown.mockResolvedValue(markdownApiResult); @@ -170,18 +186,14 @@ describe('content_editor/extensions/attachment', () => { mock.onPost().reply(httpStatus.OK, successResponse); }); - it('inserts a loading mark', (done) => { + it('inserts a loading mark', async () => { const expectedDoc = doc(p(loading({ label: 'test-file' }))); - tiptapEditor.on( - 'update', - once(() => { - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); - done(); - }), - ); - - tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); + await expectDocumentAfterTransaction({ + number: 1, + expectedDoc, + action: () => tiptapEditor.commands.uploadAttachment({ file: attachmentFile }), + }); }); it('updates the loading mark with a link with canonicalSrc and href attrs', async () => { @@ -198,11 +210,11 @@ describe('content_editor/extensions/attachment', () => { ), ); - tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); - - await waitForPromises(); - - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + await expectDocumentAfterTransaction({ + number: 2, + expectedDoc, + action: () => tiptapEditor.commands.uploadAttachment({ file: attachmentFile }), + }); }); }); @@ -214,11 +226,11 @@ describe('content_editor/extensions/attachment', () => { it('resets the doc to orginal state', async () => { const expectedDoc = doc(p('')); - tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); - - await waitForPromises(); - - expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + await expectDocumentAfterTransaction({ + number: 2, + expectedDoc, + action: () => tiptapEditor.commands.uploadAttachment({ file: attachmentFile }), + }); }); it('emits an error event that includes an error message', (done) => { diff --git a/spec/frontend/content_editor/extensions/blockquote_spec.js b/spec/frontend/content_editor/extensions/blockquote_spec.js new file mode 100644 index 00000000000..c5b5044352d --- /dev/null +++ b/spec/frontend/content_editor/extensions/blockquote_spec.js @@ -0,0 +1,19 @@ +import { multilineInputRegex } from '~/content_editor/extensions/blockquote'; + +describe('content_editor/extensions/blockquote', () => { + describe.each` + input | matches + ${'>>> '} | ${true} + ${' >>> '} | ${true} + ${'\t>>> '} | ${true} + ${'>> '} | ${false} + ${'>>>x '} | ${false} + ${'> '} | ${false} + `('multilineInputRegex', ({ input, matches }) => { + it(`${matches ? 'matches' : 'does not match'}: "${input}"`, () => { + const match = new RegExp(multilineInputRegex).test(input); + + expect(match).toBe(matches); + }); + }); +}); diff --git a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js index 188e6580dc6..6a0a0c76825 100644 --- a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js +++ b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js @@ -1,9 +1,15 @@ import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; -import { loadMarkdownApiResult } from '../markdown_processing_examples'; import { createTestEditor } from '../test_utils'; +const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true"> + <code> + <span id="LC1" class="line" lang="javascript"> + <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">hello world</span><span class="dl">'</span><span class="p">)</span> + </span> + </code> +</pre>`; + describe('content_editor/extensions/code_block_highlight', () => { - let codeBlockHtmlFixture; let parsedCodeBlockHtmlFixture; let tiptapEditor; @@ -11,13 +17,10 @@ describe('content_editor/extensions/code_block_highlight', () => { const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre'); beforeEach(() => { - const { html } = loadMarkdownApiResult('code_block'); - tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] }); - codeBlockHtmlFixture = html; - parsedCodeBlockHtmlFixture = parseHTML(codeBlockHtmlFixture); + parsedCodeBlockHtmlFixture = parseHTML(CODE_BLOCK_HTML); - tiptapEditor.commands.setContent(codeBlockHtmlFixture); + tiptapEditor.commands.setContent(CODE_BLOCK_HTML); }); it('extracts language and params attributes from Markdown API output', () => { diff --git a/spec/frontend/content_editor/markdown_processing_examples.js b/spec/frontend/content_editor/markdown_processing_examples.js index 12eed00f3c6..b3aabfeb145 100644 --- a/spec/frontend/content_editor/markdown_processing_examples.js +++ b/spec/frontend/content_editor/markdown_processing_examples.js @@ -6,7 +6,8 @@ import { getJSONFixture } from 'helpers/fixtures'; export const loadMarkdownApiResult = (testName) => { const fixturePathPrefix = `api/markdown/${testName}.json`; - return getJSONFixture(fixturePathPrefix); + const fixture = getJSONFixture(fixturePathPrefix); + return fixture.body || fixture.html; }; export const loadMarkdownApiExamples = () => { @@ -16,3 +17,9 @@ export const loadMarkdownApiExamples = () => { return apiMarkdownExampleObjects.map(({ name, context, markdown }) => [name, context, markdown]); }; + +export const loadMarkdownApiExample = (testName) => { + return loadMarkdownApiExamples().find(([name, context]) => { + return (context ? `${context}_${name}` : name) === testName; + })[2]; +}; diff --git a/spec/frontend/content_editor/markdown_processing_spec.js b/spec/frontend/content_editor/markdown_processing_spec.js index da3f6e64db8..71565768558 100644 --- a/spec/frontend/content_editor/markdown_processing_spec.js +++ b/spec/frontend/content_editor/markdown_processing_spec.js @@ -9,8 +9,9 @@ describe('markdown processing', () => { 'correctly handles %s (context: %s)', async (name, context, markdown) => { const testName = context ? `${context}_${name}` : name; - const { html, body } = loadMarkdownApiResult(testName); - const contentEditor = createContentEditor({ renderMarkdown: () => html || body }); + const contentEditor = createContentEditor({ + renderMarkdown: () => loadMarkdownApiResult(testName), + }); await contentEditor.setSerializedContent(markdown); expect(contentEditor.getSerializedContent()).toBe(markdown); diff --git a/spec/frontend/content_editor/services/mark_utils_spec.js b/spec/frontend/content_editor/services/mark_utils_spec.js new file mode 100644 index 00000000000..bbfb8f26f99 --- /dev/null +++ b/spec/frontend/content_editor/services/mark_utils_spec.js @@ -0,0 +1,38 @@ +import { + markInputRegex, + extractMarkAttributesFromMatch, +} from '~/content_editor/services/mark_utils'; + +describe('content_editor/services/mark_utils', () => { + describe.each` + tag | input | matches + ${'tag'} | ${'<tag>hello</tag>'} | ${true} + ${'tag'} | ${'<tag title="tooltip">hello</tag>'} | ${true} + ${'kbd'} | ${'Hold <kbd>Ctrl</kbd>'} | ${true} + ${'time'} | ${'Lets meet at <time title="today" datetime="20:00">20:00</time>'} | ${true} + ${'tag'} | ${'<tag width=30 height=30>attrs not quoted</tag>'} | ${false} + ${'tag'} | ${"<tag title='abc'>single quote attrs not supported</tag>"} | ${false} + ${'tag'} | ${'<tag title>attr has no value</tag>'} | ${false} + ${'tag'} | ${'<tag>tag opened but not closed'} | ${false} + ${'tag'} | ${'</tag>tag closed before opened<tag>'} | ${false} + `('inputRegex("$tag")', ({ tag, input, matches }) => { + it(`${matches ? 'matches' : 'does not match'}: "${input}"`, () => { + const match = markInputRegex(tag).test(input); + + expect(match).toBe(matches); + }); + }); + + describe.each` + tag | input | attrs + ${'kbd'} | ${'Hold <kbd>Ctrl</kbd>'} | ${{}} + ${'tag'} | ${'<tag title="tooltip">hello</tag>'} | ${{ title: 'tooltip' }} + ${'time'} | ${'Lets meet at <time title="today" datetime="20:00">20:00</time>'} | ${{ title: 'today', datetime: '20:00' }} + ${'abbr'} | ${'Sure, you can try it out but <abbr title="Your mileage may vary">YMMV</abbr>'} | ${{ title: 'Your mileage may vary' }} + `('extractAttributesFromMatch(inputRegex("$tag").exec(\'$input\'))', ({ tag, input, attrs }) => { + it(`returns: "${JSON.stringify(attrs)}"`, () => { + const matches = markInputRegex(tag).exec(input); + expect(extractMarkAttributesFromMatch(matches)).toEqual(attrs); + }); + }); +}); diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js new file mode 100644 index 00000000000..6f2c908c289 --- /dev/null +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -0,0 +1,1008 @@ +import Blockquote from '~/content_editor/extensions/blockquote'; +import Bold from '~/content_editor/extensions/bold'; +import BulletList from '~/content_editor/extensions/bullet_list'; +import Code from '~/content_editor/extensions/code'; +import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; +import DescriptionItem from '~/content_editor/extensions/description_item'; +import DescriptionList from '~/content_editor/extensions/description_list'; +import Division from '~/content_editor/extensions/division'; +import Emoji from '~/content_editor/extensions/emoji'; +import Figure from '~/content_editor/extensions/figure'; +import FigureCaption from '~/content_editor/extensions/figure_caption'; +import HardBreak from '~/content_editor/extensions/hard_break'; +import Heading from '~/content_editor/extensions/heading'; +import HorizontalRule from '~/content_editor/extensions/horizontal_rule'; +import Image from '~/content_editor/extensions/image'; +import InlineDiff from '~/content_editor/extensions/inline_diff'; +import Italic from '~/content_editor/extensions/italic'; +import Link from '~/content_editor/extensions/link'; +import ListItem from '~/content_editor/extensions/list_item'; +import OrderedList from '~/content_editor/extensions/ordered_list'; +import Paragraph from '~/content_editor/extensions/paragraph'; +import Strike from '~/content_editor/extensions/strike'; +import Table from '~/content_editor/extensions/table'; +import TableCell from '~/content_editor/extensions/table_cell'; +import TableHeader from '~/content_editor/extensions/table_header'; +import TableRow from '~/content_editor/extensions/table_row'; +import TaskItem from '~/content_editor/extensions/task_item'; +import TaskList from '~/content_editor/extensions/task_list'; +import Text from '~/content_editor/extensions/text'; +import markdownSerializer from '~/content_editor/services/markdown_serializer'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +jest.mock('~/emoji'); + +jest.mock('~/content_editor/services/feature_flags', () => ({ + isBlockTablesFeatureEnabled: jest.fn().mockReturnValue(true), +})); + +const tiptapEditor = createTestEditor({ + extensions: [ + Blockquote, + Bold, + BulletList, + Code, + CodeBlockHighlight, + DescriptionItem, + DescriptionList, + Division, + Emoji, + Figure, + FigureCaption, + HardBreak, + Heading, + HorizontalRule, + Image, + InlineDiff, + Italic, + Link, + ListItem, + OrderedList, + Paragraph, + Strike, + Table, + TableCell, + TableHeader, + TableRow, + TaskItem, + TaskList, + Text, + ], +}); + +const { + builders: { + doc, + blockquote, + bold, + bulletList, + code, + codeBlock, + division, + descriptionItem, + descriptionList, + emoji, + figure, + figureCaption, + heading, + hardBreak, + horizontalRule, + image, + inlineDiff, + italic, + link, + listItem, + orderedList, + paragraph, + strike, + table, + tableCell, + tableHeader, + tableRow, + taskItem, + taskList, + }, +} = createDocBuilder({ + tiptapEditor, + names: { + blockquote: { nodeType: Blockquote.name }, + bold: { markType: Bold.name }, + bulletList: { nodeType: BulletList.name }, + code: { markType: Code.name }, + codeBlock: { nodeType: CodeBlockHighlight.name }, + division: { nodeType: Division.name }, + descriptionItem: { nodeType: DescriptionItem.name }, + descriptionList: { nodeType: DescriptionList.name }, + emoji: { markType: Emoji.name }, + figure: { nodeType: Figure.name }, + figureCaption: { nodeType: FigureCaption.name }, + hardBreak: { nodeType: HardBreak.name }, + heading: { nodeType: Heading.name }, + horizontalRule: { nodeType: HorizontalRule.name }, + image: { nodeType: Image.name }, + inlineDiff: { markType: InlineDiff.name }, + italic: { nodeType: Italic.name }, + link: { markType: Link.name }, + listItem: { nodeType: ListItem.name }, + orderedList: { nodeType: OrderedList.name }, + paragraph: { nodeType: Paragraph.name }, + strike: { markType: Strike.name }, + table: { nodeType: Table.name }, + tableCell: { nodeType: TableCell.name }, + tableHeader: { nodeType: TableHeader.name }, + tableRow: { nodeType: TableRow.name }, + taskItem: { nodeType: TaskItem.name }, + taskList: { nodeType: TaskList.name }, + }, +}); + +const serialize = (...content) => + markdownSerializer({}).serialize({ + schema: tiptapEditor.schema, + content: doc(...content).toJSON(), + }); + +describe('markdownSerializer', () => { + it('correctly serializes bold', () => { + expect(serialize(paragraph(bold('bold')))).toBe('**bold**'); + }); + + it('correctly serializes italics', () => { + expect(serialize(paragraph(italic('italics')))).toBe('_italics_'); + }); + + it('correctly serializes inline diff', () => { + expect( + serialize( + paragraph( + inlineDiff({ type: 'addition' }, '+30 lines'), + inlineDiff({ type: 'deletion' }, '-10 lines'), + ), + ), + ).toBe('{++30 lines+}{--10 lines-}'); + }); + + it('correctly serializes a line break', () => { + expect(serialize(paragraph('hello', hardBreak(), 'world'))).toBe('hello\\\nworld'); + }); + + it('correctly serializes a link', () => { + expect(serialize(paragraph(link({ href: 'https://example.com' }, 'example url')))).toBe( + '[example url](https://example.com)', + ); + }); + + it('correctly serializes a plain URL link', () => { + expect(serialize(paragraph(link({ href: 'https://example.com' }, 'https://example.com')))).toBe( + '<https://example.com>', + ); + }); + + it('correctly serializes a link with a title', () => { + expect( + serialize( + paragraph(link({ href: 'https://example.com', title: 'click this link' }, 'example url')), + ), + ).toBe('[example url](https://example.com "click this link")'); + }); + + it('correctly serializes a plain URL link with a title', () => { + expect( + serialize( + paragraph( + link({ href: 'https://example.com', title: 'link title' }, 'https://example.com'), + ), + ), + ).toBe('[https://example.com](https://example.com "link title")'); + }); + + it('correctly serializes a link with a canonicalSrc', () => { + expect( + serialize( + paragraph( + link( + { + href: '/uploads/abcde/file.zip', + canonicalSrc: 'file.zip', + title: 'click here to download', + }, + 'download file', + ), + ), + ), + ).toBe('[download file](file.zip "click here to download")'); + }); + + it('correctly serializes strikethrough', () => { + expect(serialize(paragraph(strike('deleted content')))).toBe('~~deleted content~~'); + }); + + it('correctly serializes blockquotes with hard breaks', () => { + expect(serialize(blockquote('some text', hardBreak(), hardBreak(), 'new line'))).toBe( + ` +> some text\\ +> \\ +> new line + `.trim(), + ); + }); + + it('correctly serializes blockquote with multiple block nodes', () => { + expect(serialize(blockquote(paragraph('some paragraph'), codeBlock('var x = 10;')))).toBe( + ` +> some paragraph +> +> \`\`\` +> var x = 10; +> \`\`\` + `.trim(), + ); + }); + + it('correctly serializes a multiline blockquote', () => { + expect( + serialize( + blockquote( + { multiline: true }, + paragraph('some paragraph with ', bold('bold')), + codeBlock('var y = 10;'), + ), + ), + ).toBe( + ` +>>> +some paragraph with **bold** + +\`\`\` +var y = 10; +\`\`\` + +>>> + `.trim(), + ); + }); + + it('correctly serializes a code block with language', () => { + expect( + serialize( + codeBlock( + { language: 'json' }, + 'this is not really json but just trying out whether this case works or not', + ), + ), + ).toBe( + ` +\`\`\`json +this is not really json but just trying out whether this case works or not +\`\`\` + `.trim(), + ); + }); + + it('correctly serializes emoji', () => { + expect(serialize(paragraph(emoji({ name: 'dog' })))).toBe(':dog:'); + }); + + it('correctly serializes headings', () => { + expect( + serialize( + heading({ level: 1 }, 'Heading 1'), + heading({ level: 2 }, 'Heading 2'), + heading({ level: 3 }, 'Heading 3'), + heading({ level: 4 }, 'Heading 4'), + heading({ level: 5 }, 'Heading 5'), + heading({ level: 6 }, 'Heading 6'), + ), + ).toBe( + ` +# Heading 1 + +## Heading 2 + +### Heading 3 + +#### Heading 4 + +##### Heading 5 + +###### Heading 6 + `.trim(), + ); + }); + + it('correctly serializes horizontal rule', () => { + expect(serialize(horizontalRule(), horizontalRule(), horizontalRule())).toBe( + ` +--- + +--- + +--- + `.trim(), + ); + }); + + it('correctly serializes an image', () => { + expect(serialize(paragraph(image({ src: 'img.jpg', alt: 'foo bar' })))).toBe( + '![foo bar](img.jpg)', + ); + }); + + it('correctly serializes an image with a title', () => { + expect(serialize(paragraph(image({ src: 'img.jpg', title: 'baz', alt: 'foo bar' })))).toBe( + '![foo bar](img.jpg "baz")', + ); + }); + + it('correctly serializes an image with a canonicalSrc', () => { + expect( + serialize( + paragraph( + image({ + src: '/uploads/abcde/file.png', + alt: 'this is an image', + canonicalSrc: 'file.png', + title: 'foo bar baz', + }), + ), + ), + ).toBe('![this is an image](file.png "foo bar baz")'); + }); + + it('correctly serializes bullet list', () => { + expect( + serialize( + bulletList( + listItem(paragraph('list item 1')), + listItem(paragraph('list item 2')), + listItem(paragraph('list item 3')), + ), + ), + ).toBe( + ` +* list item 1 +* list item 2 +* list item 3 + `.trim(), + ); + }); + + it('correctly serializes bullet list with different bullet styles', () => { + expect( + serialize( + bulletList( + { bullet: '+' }, + listItem(paragraph('list item 1')), + listItem(paragraph('list item 2')), + listItem( + paragraph('list item 3'), + bulletList( + { bullet: '-' }, + listItem(paragraph('sub-list item 1')), + listItem(paragraph('sub-list item 2')), + ), + ), + ), + ), + ).toBe( + ` ++ list item 1 ++ list item 2 ++ list item 3 + - sub-list item 1 + - sub-list item 2 + `.trim(), + ); + }); + + it('correctly serializes a numeric list', () => { + expect( + serialize( + orderedList( + listItem(paragraph('list item 1')), + listItem(paragraph('list item 2')), + listItem(paragraph('list item 3')), + ), + ), + ).toBe( + ` +1. list item 1 +2. list item 2 +3. list item 3 + `.trim(), + ); + }); + + it('correctly serializes a numeric list with parens', () => { + expect( + serialize( + orderedList( + { parens: true }, + listItem(paragraph('list item 1')), + listItem(paragraph('list item 2')), + listItem(paragraph('list item 3')), + ), + ), + ).toBe( + ` +1) list item 1 +2) list item 2 +3) list item 3 + `.trim(), + ); + }); + + it('correctly serializes a numeric list with a different start order', () => { + expect( + serialize( + orderedList( + { start: 17 }, + listItem(paragraph('list item 1')), + listItem(paragraph('list item 2')), + listItem(paragraph('list item 3')), + ), + ), + ).toBe( + ` +17. list item 1 +18. list item 2 +19. list item 3 + `.trim(), + ); + }); + + it('correctly serializes a numeric list with an invalid start order', () => { + expect( + serialize( + orderedList( + { start: NaN }, + listItem(paragraph('list item 1')), + listItem(paragraph('list item 2')), + listItem(paragraph('list item 3')), + ), + ), + ).toBe( + ` +1. list item 1 +2. list item 2 +3. list item 3 + `.trim(), + ); + }); + + it('correctly serializes a bullet list inside an ordered list', () => { + expect( + serialize( + orderedList( + { start: 17 }, + listItem(paragraph('list item 1')), + listItem(paragraph('list item 2')), + listItem( + paragraph('list item 3'), + bulletList( + listItem(paragraph('sub-list item 1')), + listItem(paragraph('sub-list item 2')), + ), + ), + ), + ), + ).toBe( + // notice that 4 space indent works fine in this case, + // when it usually wouldn't + ` +17. list item 1 +18. list item 2 +19. list item 3 + * sub-list item 1 + * sub-list item 2 + `.trim(), + ); + }); + + it('correctly serializes a task list', () => { + expect( + serialize( + taskList( + taskItem({ checked: true }, paragraph('list item 1')), + taskItem(paragraph('list item 2')), + taskItem( + paragraph('list item 3'), + taskList( + taskItem({ checked: true }, paragraph('sub-list item 1')), + taskItem(paragraph('sub-list item 2')), + ), + ), + ), + ), + ).toBe( + ` +* [x] list item 1 +* [ ] list item 2 +* [ ] list item 3 + * [x] sub-list item 1 + * [ ] sub-list item 2 + `.trim(), + ); + }); + + it('correctly serializes a numeric task list + with start order', () => { + expect( + serialize( + taskList( + { numeric: true }, + taskItem({ checked: true }, paragraph('list item 1')), + taskItem(paragraph('list item 2')), + taskItem( + paragraph('list item 3'), + taskList( + { numeric: true, start: 1351, parens: true }, + taskItem({ checked: true }, paragraph('sub-list item 1')), + taskItem(paragraph('sub-list item 2')), + ), + ), + ), + ), + ).toBe( + ` +1. [x] list item 1 +2. [ ] list item 2 +3. [ ] list item 3 + 1351) [x] sub-list item 1 + 1352) [ ] sub-list item 2 + `.trim(), + ); + }); + + it('correctly renders a description list', () => { + expect( + serialize( + descriptionList( + descriptionItem(paragraph('Beast of Bodmin')), + descriptionItem({ isTerm: false }, paragraph('A large feline inhabiting Bodmin Moor.')), + + descriptionItem(paragraph('Morgawr')), + descriptionItem({ isTerm: false }, paragraph('A sea serpent.')), + + descriptionItem(paragraph('Owlman')), + descriptionItem( + { isTerm: false }, + paragraph('A giant ', italic('owl-like'), ' creature.'), + ), + ), + ), + ).toBe( + ` +<dl> +<dt>Beast of Bodmin</dt> +<dd>A large feline inhabiting Bodmin Moor.</dd> +<dt>Morgawr</dt> +<dd>A sea serpent.</dd> +<dt>Owlman</dt> +<dd> + +A giant _owl-like_ creature. + +</dd> +</dl> + `.trim(), + ); + }); + + it('correctly renders div', () => { + expect( + serialize( + division(paragraph('just a paragraph in a div')), + division(paragraph('just some ', bold('styled'), ' ', italic('content'), ' in a div')), + ), + ).toBe( + '<div>just a paragraph in a div</div>\n<div>\n\njust some **styled** _content_ in a div\n\n</div>', + ); + }); + + it('correctly renders figure', () => { + expect( + serialize( + figure( + paragraph(image({ src: 'elephant.jpg', alt: 'An elephant at sunset' })), + figureCaption('An elephant at sunset'), + ), + ), + ).toBe( + ` +<figure> + +![An elephant at sunset](elephant.jpg) + +<figcaption>An elephant at sunset</figcaption> +</figure> + `.trim(), + ); + }); + + it('correctly renders figure with styled caption', () => { + expect( + serialize( + figure( + paragraph(image({ src: 'elephant.jpg', alt: 'An elephant at sunset' })), + figureCaption(italic('An elephant at sunset')), + ), + ), + ).toBe( + ` +<figure> + +![An elephant at sunset](elephant.jpg) + +<figcaption> + +_An elephant at sunset_ + +</figcaption> +</figure> + `.trim(), + ); + }); + + it('correctly serializes a table with inline content', () => { + expect( + serialize( + table( + // each table cell must contain at least one paragraph + tableRow( + tableHeader(paragraph('header')), + tableHeader(paragraph('header')), + tableHeader(paragraph('header')), + ), + tableRow( + tableCell(paragraph('cell')), + tableCell(paragraph('cell')), + tableCell(paragraph('cell')), + ), + tableRow( + tableCell(paragraph('cell')), + tableCell(paragraph('cell')), + tableCell(paragraph('cell')), + ), + ), + ).trim(), + ).toBe( + ` +| header | header | header | +|--------|--------|--------| +| cell | cell | cell | +| cell | cell | cell | + `.trim(), + ); + }); + + it('correctly serializes a table with line breaks', () => { + expect( + serialize( + table( + tableRow(tableHeader(paragraph('header')), tableHeader(paragraph('header'))), + tableRow( + tableCell(paragraph('cell with', hardBreak(), 'line', hardBreak(), 'breaks')), + tableCell(paragraph('cell')), + ), + tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))), + ), + ).trim(), + ).toBe( + ` +| header | header | +|--------|--------| +| cell with<br>line<br>breaks | cell | +| cell | cell | + `.trim(), + ); + }); + + it('correctly serializes two consecutive tables', () => { + expect( + serialize( + table( + tableRow(tableHeader(paragraph('header')), tableHeader(paragraph('header'))), + tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))), + tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))), + ), + table( + tableRow(tableHeader(paragraph('header')), tableHeader(paragraph('header'))), + tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))), + tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))), + ), + ).trim(), + ).toBe( + ` +| header | header | +|--------|--------| +| cell | cell | +| cell | cell | + +| header | header | +|--------|--------| +| cell | cell | +| cell | cell | + `.trim(), + ); + }); + + it('correctly serializes a table with block content', () => { + expect( + serialize( + table( + tableRow( + tableHeader(paragraph('examples of')), + tableHeader(paragraph('block content')), + tableHeader(paragraph('in tables')), + tableHeader(paragraph('in content editor')), + ), + tableRow( + tableCell(heading({ level: 1 }, 'heading 1')), + tableCell(heading({ level: 2 }, 'heading 2')), + tableCell(paragraph(bold('just bold'))), + tableCell(paragraph(bold('bold'), ' ', italic('italic'), ' ', code('code'))), + ), + tableRow( + tableCell( + paragraph('all marks in three paragraphs:'), + paragraph('the ', bold('quick'), ' ', italic('brown'), ' ', code('fox')), + paragraph( + link({ href: '/home' }, 'jumps'), + ' over the ', + strike('lazy'), + ' ', + emoji({ name: 'dog' }), + ), + ), + tableCell( + paragraph(image({ src: 'img.jpg', alt: 'some image' }), hardBreak(), 'image content'), + ), + tableCell( + blockquote('some text', hardBreak(), hardBreak(), 'in a multiline blockquote'), + ), + tableCell( + codeBlock( + { language: 'javascript' }, + 'var a = 2;\nvar b = 3;\nvar c = a + d;\n\nconsole.log(c);', + ), + ), + ), + tableRow( + tableCell(bulletList(listItem('item 1'), listItem('item 2'), listItem('item 2'))), + tableCell(orderedList(listItem('item 1'), listItem('item 2'), listItem('item 2'))), + tableCell( + paragraph('paragraphs separated by'), + horizontalRule(), + paragraph('a horizontal rule'), + ), + tableCell( + table( + tableRow(tableHeader(paragraph('table')), tableHeader(paragraph('inside'))), + tableRow(tableCell(paragraph('another')), tableCell(paragraph('table'))), + ), + ), + ), + ), + ).trim(), + ).toBe( + ` +<table> +<tr> +<th>examples of</th> +<th>block content</th> +<th>in tables</th> +<th>in content editor</th> +</tr> +<tr> +<td> + +# heading 1 +</td> +<td> + +## heading 2 +</td> +<td> + +**just bold** +</td> +<td> + +**bold** _italic_ \`code\` +</td> +</tr> +<tr> +<td> + +all marks in three paragraphs: + +the **quick** _brown_ \`fox\` + +[jumps](/home) over the ~~lazy~~ :dog: +</td> +<td> + +![some image](img.jpg)<br>image content +</td> +<td> + +> some text\\ +> \\ +> in a multiline blockquote +</td> +<td> + +\`\`\`javascript +var a = 2; +var b = 3; +var c = a + d; + +console.log(c); +\`\`\` +</td> +</tr> +<tr> +<td> + +* item 1 +* item 2 +* item 2 +</td> +<td> + +1. item 1 +2. item 2 +3. item 2 +</td> +<td> + +paragraphs separated by + +--- + +a horizontal rule +</td> +<td> + +| table | inside | +|-------|--------| +| another | table | + +</td> +</tr> +</table> + `.trim(), + ); + }); + + it('correctly renders content after a markdown table', () => { + expect( + serialize( + table(tableRow(tableHeader(paragraph('header'))), tableRow(tableCell(paragraph('cell')))), + heading({ level: 1 }, 'this is a heading'), + ).trim(), + ).toBe( + ` +| header | +|--------| +| cell | + +# this is a heading + `.trim(), + ); + }); + + it('correctly renders content after an html table', () => { + expect( + serialize( + table( + tableRow(tableHeader(paragraph('header'))), + tableRow(tableCell(blockquote('hi'), paragraph('there'))), + ), + heading({ level: 1 }, 'this is a heading'), + ).trim(), + ).toBe( + ` +<table> +<tr> +<th>header</th> +</tr> +<tr> +<td> + +> hi + +there +</td> +</tr> +</table> + +# this is a heading + `.trim(), + ); + }); + + it('correctly serializes tables with misplaced header cells', () => { + expect( + serialize( + table( + tableRow(tableHeader(paragraph('cell')), tableCell(paragraph('cell'))), + tableRow(tableCell(paragraph('cell')), tableHeader(paragraph('cell'))), + ), + ).trim(), + ).toBe( + ` +<table> +<tr> +<th>cell</th> +<td>cell</td> +</tr> +<tr> +<td>cell</td> +<th>cell</th> +</tr> +</table> + `.trim(), + ); + }); + + it('correctly serializes table without any headers', () => { + expect( + serialize( + table( + tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))), + tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))), + ), + ).trim(), + ).toBe( + ` +<table> +<tr> +<td>cell</td> +<td>cell</td> +</tr> +<tr> +<td>cell</td> +<td>cell</td> +</tr> +</table> + `.trim(), + ); + }); + + it('correctly serializes table with rowspan and colspan', () => { + expect( + serialize( + table( + tableRow( + tableHeader(paragraph('header')), + tableHeader(paragraph('header')), + tableHeader(paragraph('header')), + ), + tableRow( + tableCell({ colspan: 2 }, paragraph('cell with rowspan: 2')), + tableCell({ rowspan: 2 }, paragraph('cell')), + ), + tableRow(tableCell({ colspan: 2 }, paragraph('cell with rowspan: 2'))), + ), + ).trim(), + ).toBe( + ` +<table> +<tr> +<th>header</th> +<th>header</th> +<th>header</th> +</tr> +<tr> +<td colspan="2">cell with rowspan: 2</td> +<td rowspan="2">cell</td> +</tr> +<tr> +<td colspan="2">cell with rowspan: 2</td> +</tr> +</table> + `.trim(), + ); + }); +}); diff --git a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js new file mode 100644 index 00000000000..6f908f468f6 --- /dev/null +++ b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js @@ -0,0 +1,81 @@ +import { Extension } from '@tiptap/core'; +import BulletList from '~/content_editor/extensions/bullet_list'; +import ListItem from '~/content_editor/extensions/list_item'; +import Paragraph from '~/content_editor/extensions/paragraph'; +import markdownSerializer from '~/content_editor/services/markdown_serializer'; +import { getMarkdownSource } from '~/content_editor/services/markdown_sourcemap'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +const BULLET_LIST_MARKDOWN = `+ list item 1 ++ list item 2 + - embedded list item 3`; +const BULLET_LIST_HTML = `<ul data-sourcepos="1:1-3:24" dir="auto"> + <li data-sourcepos="1:1-1:13">list item 1</li> + <li data-sourcepos="2:1-3:24">list item 2 + <ul data-sourcepos="3:3-3:24"> + <li data-sourcepos="3:3-3:24">embedded list item 3</li> + </ul> + </li> +</ul>`; + +const SourcemapExtension = Extension.create({ + // lets add `source` attribute to every element using `getMarkdownSource` + addGlobalAttributes() { + return [ + { + types: [Paragraph.name, BulletList.name, ListItem.name], + attributes: { + source: { + parseHTML: (element) => { + const source = getMarkdownSource(element); + return source; + }, + }, + }, + }, + ]; + }, +}); + +const tiptapEditor = createTestEditor({ + extensions: [BulletList, ListItem, SourcemapExtension], +}); + +const { + builders: { doc, bulletList, listItem, paragraph }, +} = createDocBuilder({ + tiptapEditor, + names: { + bulletList: { nodeType: BulletList.name }, + listItem: { nodeType: ListItem.name }, + }, +}); + +describe('content_editor/services/markdown_sourcemap', () => { + it('gets markdown source for a rendered HTML element', async () => { + const deserialized = await markdownSerializer({ + render: () => BULLET_LIST_HTML, + serializerConfig: {}, + }).deserialize({ + schema: tiptapEditor.schema, + content: BULLET_LIST_MARKDOWN, + }); + + const expected = doc( + bulletList( + { bullet: '+', source: '+ list item 1\n+ list item 2' }, + listItem({ source: '+ list item 1' }, paragraph('list item 1')), + listItem( + { source: '+ list item 2' }, + paragraph('list item 2'), + bulletList( + { bullet: '-', source: '- embedded list item 3' }, + listItem({ source: '- embedded list item 3' }, paragraph('embedded list item 3')), + ), + ), + ), + ); + + expect(deserialized).toEqual(expected.toJSON()); + }); +}); diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js index b5a2abc2389..cf5aa3f2938 100644 --- a/spec/frontend/content_editor/test_utils.js +++ b/spec/frontend/content_editor/test_utils.js @@ -98,9 +98,7 @@ export const createTestContentEditorExtension = ({ commands = [] } = {}) => { return { labelName: { default: null, - parseHTML: (element) => { - return { labelName: element.dataset.labelName }; - }, + parseHTML: (element) => element.dataset.labelName, }, }; }, diff --git a/spec/frontend/cycle_analytics/banner_spec.js b/spec/frontend/cycle_analytics/banner_spec.js deleted file mode 100644 index ef7998c5ff5..00000000000 --- a/spec/frontend/cycle_analytics/banner_spec.js +++ /dev/null @@ -1,47 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Banner from '~/cycle_analytics/components/banner.vue'; - -describe('Value Stream Analytics banner', () => { - let wrapper; - - const createComponent = () => { - wrapper = shallowMount(Banner, { - propsData: { - documentationLink: 'path', - }, - }); - }; - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('should render value stream analytics information', () => { - expect(wrapper.find('h4').text().trim()).toBe('Introducing Value Stream Analytics'); - - expect( - wrapper - .find('p') - .text() - .trim() - .replace(/[\r\n]+/g, ' '), - ).toContain( - 'Value Stream Analytics gives an overview of how much time it takes to go from idea to production in your project.', - ); - - expect(wrapper.find('a').text().trim()).toBe('Read more'); - expect(wrapper.find('a').attributes('href')).toBe('path'); - }); - - it('should emit an event when close button is clicked', async () => { - jest.spyOn(wrapper.vm, '$emit').mockImplementation(() => {}); - - await wrapper.find('.js-ca-dismiss-button').trigger('click'); - - expect(wrapper.vm.$emit).toHaveBeenCalled(); - }); -}); diff --git a/spec/frontend/cycle_analytics/base_spec.js b/spec/frontend/cycle_analytics/base_spec.js index 71830eed3ef..5d3361bfa35 100644 --- a/spec/frontend/cycle_analytics/base_spec.js +++ b/spec/frontend/cycle_analytics/base_spec.js @@ -6,6 +6,7 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import BaseComponent from '~/cycle_analytics/components/base.vue'; import PathNavigation from '~/cycle_analytics/components/path_navigation.vue'; import StageTable from '~/cycle_analytics/components/stage_table.vue'; +import ValueStreamFilters from '~/cycle_analytics/components/value_stream_filters.vue'; import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue'; import { NOT_ENOUGH_DATA_ERROR } from '~/cycle_analytics/constants'; import initState from '~/cycle_analytics/store/state'; @@ -30,13 +31,14 @@ Vue.use(Vuex); let wrapper; +const { id: groupId, path: groupPath } = currentGroup; const defaultState = { permissions, currentGroup, createdBefore, createdAfter, stageCounts, - endpoints: { fullPath }, + endpoints: { fullPath, groupId, groupPath }, }; function createStore({ initialState = {}, initialGetters = {} }) { @@ -74,6 +76,7 @@ function createComponent({ initialState, initialGetters } = {}) { const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findPathNavigation = () => wrapper.findComponent(PathNavigation); +const findFilters = () => wrapper.findComponent(ValueStreamFilters); const findOverviewMetrics = () => wrapper.findComponent(ValueStreamMetrics); const findStageTable = () => wrapper.findComponent(StageTable); const findStageEvents = () => findStageTable().props('stageEvents'); @@ -123,6 +126,29 @@ describe('Value stream analytics component', () => { expect(findStageEvents()).toEqual(selectedStageEvents); }); + it('renders the filters', () => { + expect(findFilters().exists()).toBe(true); + }); + + it('displays the date range selector and hides the project selector', () => { + expect(findFilters().props()).toMatchObject({ + hasProjectFilter: false, + hasDateRangeFilter: true, + }); + }); + + it('passes the paths to the filter bar', () => { + expect(findFilters().props()).toEqual({ + groupId, + groupPath, + endDate: createdBefore, + hasDateRangeFilter: true, + hasProjectFilter: false, + selectedProjects: [], + startDate: createdAfter, + }); + }); + it('does not render the loading icon', () => { expect(findLoadingIcon().exists()).toBe(false); }); diff --git a/spec/frontend/cycle_analytics/stage_table_spec.js b/spec/frontend/cycle_analytics/stage_table_spec.js index 47a2ce4444b..3158446c37d 100644 --- a/spec/frontend/cycle_analytics/stage_table_spec.js +++ b/spec/frontend/cycle_analytics/stage_table_spec.js @@ -22,6 +22,7 @@ const findStageEvents = () => wrapper.findAllByTestId('vsa-stage-event'); const findPagination = () => wrapper.findByTestId('vsa-stage-pagination'); const findTable = () => wrapper.findComponent(GlTable); const findTableHead = () => wrapper.find('thead'); +const findTableHeadColumns = () => findTableHead().findAll('th'); const findStageEventTitle = (ev) => extendedWrapper(ev).findByTestId('vsa-stage-event-title'); const findStageTime = () => wrapper.findByTestId('vsa-stage-event-time'); const findIcon = (name) => wrapper.findByTestId(`${name}-icon`); @@ -244,6 +245,12 @@ describe('StageTable', () => { wrapper.destroy(); }); + it('can sort the table by each column', () => { + findTableHeadColumns().wrappers.forEach((w) => { + expect(w.attributes('aria-sort')).toBe('none'); + }); + }); + it('clicking a table column will send tracking information', () => { triggerTableSort(); @@ -275,5 +282,17 @@ describe('StageTable', () => { }, ]); }); + + describe('with sortable=false', () => { + beforeEach(() => { + wrapper = createComponent({ sortable: false }); + }); + + it('cannot sort the table', () => { + findTableHeadColumns().wrappers.forEach((w) => { + expect(w.attributes('aria-sort')).toBeUndefined(); + }); + }); + }); }); }); diff --git a/spec/frontend/cycle_analytics/store/actions_spec.js b/spec/frontend/cycle_analytics/store/actions_spec.js index 915a828ff19..97b5bd03e18 100644 --- a/spec/frontend/cycle_analytics/store/actions_spec.js +++ b/spec/frontend/cycle_analytics/store/actions_spec.js @@ -4,21 +4,41 @@ import testAction from 'helpers/vuex_action_helper'; import * as actions from '~/cycle_analytics/store/actions'; import * as getters from '~/cycle_analytics/store/getters'; import httpStatusCodes from '~/lib/utils/http_status'; -import { allowedStages, selectedStage, selectedValueStream } from '../mock_data'; - +import { + allowedStages, + selectedStage, + selectedValueStream, + currentGroup, + createdAfter, + createdBefore, +} from '../mock_data'; + +const { id: groupId, path: groupPath } = currentGroup; +const mockMilestonesPath = 'mock-milestones.json'; +const mockLabelsPath = 'mock-labels.json'; const mockRequestPath = 'some/cool/path'; const mockFullPath = '/namespace/-/analytics/value_stream_analytics/value_streams'; -const mockStartDate = 30; -const mockEndpoints = { fullPath: mockFullPath, requestPath: mockRequestPath }; -const mockSetDateActionCommit = { payload: { startDate: mockStartDate }, type: 'SET_DATE_RANGE' }; - -const defaultState = { ...getters, selectedValueStream }; +const mockEndpoints = { + fullPath: mockFullPath, + requestPath: mockRequestPath, + labelsPath: mockLabelsPath, + milestonesPath: mockMilestonesPath, + groupId, + groupPath, +}; +const mockSetDateActionCommit = { + payload: { createdAfter, createdBefore }, + type: 'SET_DATE_RANGE', +}; + +const defaultState = { ...getters, selectedValueStream, createdAfter, createdBefore }; describe('Project Value Stream Analytics actions', () => { let state; let mock; beforeEach(() => { + state = { ...defaultState }; mock = new MockAdapter(axios); }); @@ -34,16 +54,17 @@ describe('Project Value Stream Analytics actions', () => { { type: 'fetchCycleAnalyticsData' }, { type: 'fetchStageData' }, { type: 'fetchStageMedians' }, + { type: 'fetchStageCountValues' }, { type: 'setLoading', payload: false }, ]; describe.each` - action | payload | expectedActions | expectedMutations - ${'setLoading'} | ${true} | ${[]} | ${[{ type: 'SET_LOADING', payload: true }]} - ${'setDateRange'} | ${{ startDate: mockStartDate }} | ${mockFetchStageDataActions} | ${[mockSetDateActionCommit]} - ${'setFilters'} | ${[]} | ${mockFetchStageDataActions} | ${[]} - ${'setSelectedStage'} | ${{ selectedStage }} | ${[{ type: 'fetchStageData' }]} | ${[{ type: 'SET_SELECTED_STAGE', payload: { selectedStage } }]} - ${'setSelectedValueStream'} | ${{ selectedValueStream }} | ${[{ type: 'fetchValueStreamStages' }, { type: 'fetchCycleAnalyticsData' }]} | ${[{ type: 'SET_SELECTED_VALUE_STREAM', payload: { selectedValueStream } }]} + action | payload | expectedActions | expectedMutations + ${'setLoading'} | ${true} | ${[]} | ${[{ type: 'SET_LOADING', payload: true }]} + ${'setDateRange'} | ${{ createdAfter, createdBefore }} | ${mockFetchStageDataActions} | ${[mockSetDateActionCommit]} + ${'setFilters'} | ${[]} | ${mockFetchStageDataActions} | ${[]} + ${'setSelectedStage'} | ${{ selectedStage }} | ${[{ type: 'fetchStageData' }]} | ${[{ type: 'SET_SELECTED_STAGE', payload: { selectedStage } }]} + ${'setSelectedValueStream'} | ${{ selectedValueStream }} | ${[{ type: 'fetchValueStreamStages' }, { type: 'fetchCycleAnalyticsData' }]} | ${[{ type: 'SET_SELECTED_VALUE_STREAM', payload: { selectedValueStream } }]} `('$action', ({ action, payload, expectedActions, expectedMutations }) => { const types = mutationTypes(expectedMutations); it(`will dispatch ${expectedActions} and commit ${types}`, () => @@ -60,6 +81,12 @@ describe('Project Value Stream Analytics actions', () => { let mockDispatch; let mockCommit; const payload = { endpoints: mockEndpoints }; + const mockFilterEndpoints = { + groupEndpoint: 'foo', + labelsEndpoint: mockLabelsPath, + milestonesEndpoint: mockMilestonesPath, + projectEndpoint: '/namespace/-/analytics/value_stream_analytics/value_streams', + }; beforeEach(() => { mockDispatch = jest.fn(() => Promise.resolve()); @@ -76,6 +103,9 @@ describe('Project Value Stream Analytics actions', () => { payload, ); expect(mockCommit).toHaveBeenCalledWith('INITIALIZE_VSA', { endpoints: mockEndpoints }); + + expect(mockDispatch).toHaveBeenCalledTimes(4); + expect(mockDispatch).toHaveBeenCalledWith('filters/setEndpoints', mockFilterEndpoints); expect(mockDispatch).toHaveBeenCalledWith('setLoading', true); expect(mockDispatch).toHaveBeenCalledWith('fetchValueStreams'); expect(mockDispatch).toHaveBeenCalledWith('setLoading', false); @@ -84,7 +114,7 @@ describe('Project Value Stream Analytics actions', () => { describe('fetchCycleAnalyticsData', () => { beforeEach(() => { - state = { endpoints: mockEndpoints }; + state = { ...defaultState, endpoints: mockEndpoints }; mock = new MockAdapter(axios); mock.onGet(mockRequestPath).reply(httpStatusCodes.OK); }); @@ -129,7 +159,6 @@ describe('Project Value Stream Analytics actions', () => { state = { ...defaultState, endpoints: mockEndpoints, - startDate: mockStartDate, selectedStage, }; mock = new MockAdapter(axios); @@ -152,7 +181,6 @@ describe('Project Value Stream Analytics actions', () => { state = { ...defaultState, endpoints: mockEndpoints, - startDate: mockStartDate, selectedStage, }; mock = new MockAdapter(axios); @@ -177,7 +205,6 @@ describe('Project Value Stream Analytics actions', () => { state = { ...defaultState, endpoints: mockEndpoints, - startDate: mockStartDate, selectedStage, }; mock = new MockAdapter(axios); diff --git a/spec/frontend/cycle_analytics/store/mutations_spec.js b/spec/frontend/cycle_analytics/store/mutations_spec.js index 7fcfef98547..628e2a4e7ae 100644 --- a/spec/frontend/cycle_analytics/store/mutations_spec.js +++ b/spec/frontend/cycle_analytics/store/mutations_spec.js @@ -1,5 +1,4 @@ import { useFakeDate } from 'helpers/fake_date'; -import { DEFAULT_DAYS_TO_DISPLAY } from '~/cycle_analytics/constants'; import * as types from '~/cycle_analytics/store/mutation_types'; import mutations from '~/cycle_analytics/store/mutations'; import { @@ -65,15 +64,16 @@ describe('Project Value Stream Analytics mutations', () => { expect(state).toMatchObject({ [stateKey]: value }); }); + const mockSetDatePayload = { createdAfter: mockCreatedAfter, createdBefore: mockCreatedBefore }; const mockInitialPayload = { endpoints: { requestPath: mockRequestPath }, currentGroup: { title: 'cool-group' }, id: 1337, + ...mockSetDatePayload, }; const mockInitializedObj = { endpoints: { requestPath: mockRequestPath }, - createdAfter: mockCreatedAfter, - createdBefore: mockCreatedBefore, + ...mockSetDatePayload, }; it.each` @@ -89,9 +89,8 @@ describe('Project Value Stream Analytics mutations', () => { it.each` mutation | payload | stateKey | value - ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'daysInPast'} | ${DEFAULT_DAYS_TO_DISPLAY} - ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdAfter'} | ${mockCreatedAfter} - ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdBefore'} | ${mockCreatedBefore} + ${types.SET_DATE_RANGE} | ${mockSetDatePayload} | ${'createdAfter'} | ${mockCreatedAfter} + ${types.SET_DATE_RANGE} | ${mockSetDatePayload} | ${'createdBefore'} | ${mockCreatedBefore} ${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true} ${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false} ${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream} diff --git a/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js b/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js index 168ddcfeacc..403d0dce3fc 100644 --- a/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js +++ b/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js @@ -1,3 +1,4 @@ +import { GlModal } from '@gitlab/ui'; import { createLocalVue, mount } from '@vue/test-utils'; import Vuex from 'vuex'; import DeployFreezeTable from '~/deploy_freeze/components/deploy_freeze_table.vue'; @@ -29,6 +30,8 @@ describe('Deploy freeze table', () => { const findAddDeployFreezeButton = () => wrapper.find('[data-testid="add-deploy-freeze"]'); const findEditDeployFreezeButton = () => wrapper.find('[data-testid="edit-deploy-freeze"]'); const findDeployFreezeTable = () => wrapper.find('[data-testid="deploy-freeze-table"]'); + const findDeleteDeployFreezeButton = () => wrapper.find('[data-testid="delete-deploy-freeze"]'); + const findDeleteDeployFreezeModal = () => wrapper.findComponent(GlModal); beforeEach(() => { createComponent(); @@ -73,6 +76,29 @@ describe('Deploy freeze table', () => { store.state.freezePeriods[0], ); }); + + it('displays delete deploy freeze button', () => { + expect(findDeleteDeployFreezeButton().exists()).toBe(true); + }); + + it('confirms a user wants to delete a deploy freeze', async () => { + const [{ freezeStart, freezeEnd, cronTimezone }] = store.state.freezePeriods; + await findDeleteDeployFreezeButton().trigger('click'); + const modal = findDeleteDeployFreezeModal(); + expect(modal.text()).toContain( + `Deploy freeze from ${freezeStart} to ${freezeEnd} in ${cronTimezone.formattedTimezone} will be removed.`, + ); + }); + + it('deletes the freeze period on confirmation', async () => { + await findDeleteDeployFreezeButton().trigger('click'); + const modal = findDeleteDeployFreezeModal(); + modal.vm.$emit('primary'); + expect(store.dispatch).toHaveBeenCalledWith( + 'deleteFreezePeriod', + store.state.freezePeriods[0], + ); + }); }); }); diff --git a/spec/frontend/deploy_freeze/helpers.js b/spec/frontend/deploy_freeze/helpers.js index bfb84142662..598f14d45f6 100644 --- a/spec/frontend/deploy_freeze/helpers.js +++ b/spec/frontend/deploy_freeze/helpers.js @@ -1,7 +1,7 @@ import { secondsToHours } from '~/lib/utils/datetime_utility'; export const freezePeriodsFixture = getJSONFixture('/api/freeze-periods/freeze_periods.json'); -export const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json'); +export const timezoneDataFixture = getJSONFixture('/timezones/short.json'); export const findTzByName = (identifier = '') => timezoneDataFixture.find(({ name }) => name.toLowerCase() === identifier.toLowerCase()); diff --git a/spec/frontend/deploy_freeze/store/actions_spec.js b/spec/frontend/deploy_freeze/store/actions_spec.js index 6bc9c4d374c..ad67afdce75 100644 --- a/spec/frontend/deploy_freeze/store/actions_spec.js +++ b/spec/frontend/deploy_freeze/store/actions_spec.js @@ -5,6 +5,7 @@ import * as actions from '~/deploy_freeze/store/actions'; import * as types from '~/deploy_freeze/store/mutation_types'; import getInitialState from '~/deploy_freeze/store/state'; import createFlash from '~/flash'; +import * as logger from '~/lib/logger'; import axios from '~/lib/utils/axios_utils'; import { freezePeriodsFixture, timezoneDataFixture } from '../helpers'; @@ -12,6 +13,7 @@ jest.mock('~/api.js'); jest.mock('~/flash.js'); describe('deploy freeze store actions', () => { + const freezePeriodFixture = freezePeriodsFixture[0]; let mock; let state; @@ -24,6 +26,7 @@ describe('deploy freeze store actions', () => { Api.freezePeriods.mockResolvedValue({ data: freezePeriodsFixture }); Api.createFreezePeriod.mockResolvedValue(); Api.updateFreezePeriod.mockResolvedValue(); + Api.deleteFreezePeriod.mockResolvedValue(); }); afterEach(() => { @@ -195,4 +198,46 @@ describe('deploy freeze store actions', () => { ); }); }); + + describe('deleteFreezePeriod', () => { + it('dispatch correct actions on deleting a freeze period', () => { + testAction( + actions.deleteFreezePeriod, + freezePeriodFixture, + state, + [ + { type: 'REQUEST_DELETE_FREEZE_PERIOD', payload: freezePeriodFixture.id }, + { type: 'RECEIVE_DELETE_FREEZE_PERIOD_SUCCESS', payload: freezePeriodFixture.id }, + ], + [], + () => + expect(Api.deleteFreezePeriod).toHaveBeenCalledWith( + state.projectId, + freezePeriodFixture.id, + ), + ); + }); + + it('should show flash error and set error in state on delete failure', () => { + jest.spyOn(logger, 'logError').mockImplementation(); + const error = new Error(); + Api.deleteFreezePeriod.mockRejectedValue(error); + + testAction( + actions.deleteFreezePeriod, + freezePeriodFixture, + state, + [ + { type: 'REQUEST_DELETE_FREEZE_PERIOD', payload: freezePeriodFixture.id }, + { type: 'RECEIVE_DELETE_FREEZE_PERIOD_ERROR', payload: freezePeriodFixture.id }, + ], + [], + () => { + expect(createFlash).toHaveBeenCalled(); + + expect(logger.logError).toHaveBeenCalledWith('Unable to delete deploy freeze', error); + }, + ); + }); + }); }); diff --git a/spec/frontend/deploy_freeze/store/mutations_spec.js b/spec/frontend/deploy_freeze/store/mutations_spec.js index f8683489340..878a755088c 100644 --- a/spec/frontend/deploy_freeze/store/mutations_spec.js +++ b/spec/frontend/deploy_freeze/store/mutations_spec.js @@ -28,9 +28,9 @@ describe('Deploy freeze mutations', () => { describe('RECEIVE_FREEZE_PERIODS_SUCCESS', () => { it('should set freeze periods and format timezones from identifiers to names', () => { const timezoneNames = { - 'Europe/Berlin': 'Berlin', - 'Etc/UTC': 'UTC', - 'America/New_York': 'Eastern Time (US & Canada)', + 'Europe/Berlin': '[UTC 2] Berlin', + 'Etc/UTC': '[UTC 0] UTC', + 'America/New_York': '[UTC -4] Eastern Time (US & Canada)', }; mutations[types.RECEIVE_FREEZE_PERIODS_SUCCESS](stateCopy, freezePeriodsFixture); diff --git a/spec/frontend/deprecated_jquery_dropdown_spec.js b/spec/frontend/deprecated_jquery_dropdown_spec.js index 7858f88f8c3..4a6dee31cd5 100644 --- a/spec/frontend/deprecated_jquery_dropdown_spec.js +++ b/spec/frontend/deprecated_jquery_dropdown_spec.js @@ -323,7 +323,7 @@ describe('deprecatedJQueryDropdown', () => { const li = dropdown.renderItem(item, null, 3); const link = li.querySelector('a'); - expect(link).toHaveAttr('data-track-event', 'click_text'); + expect(link).toHaveAttr('data-track-action', 'click_text'); expect(link).toHaveAttr('data-track-label', 'some_value_for_label'); expect(link).toHaveAttr('data-track-value', '3'); expect(link).toHaveAttr('data-track-property', 'suggestion-category'); diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap index d9f5ba0bade..4dc8eaea174 100644 --- a/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap +++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Design reply form component renders button text as "Comment" when creating a comment 1`] = ` -"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn gl-mr-3 gl-w-auto! btn-confirm btn-md disabled gl-button\\"> +"<button data-track-action=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn gl-mr-3 gl-w-auto! btn-confirm btn-md disabled gl-button\\"> <!----> <!----> <span class=\\"gl-button-text\\"> Comment @@ -9,7 +9,7 @@ exports[`Design reply form component renders button text as "Comment" when creat `; exports[`Design reply form component renders button text as "Save comment" when creating a comment 1`] = ` -"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn gl-mr-3 gl-w-auto! btn-confirm btn-md disabled gl-button\\"> +"<button data-track-action=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn gl-mr-3 gl-w-auto! btn-confirm btn-md disabled gl-button\\"> <!----> <!----> <span class=\\"gl-button-text\\"> Save comment diff --git a/spec/frontend/design_management/components/design_scaler_spec.js b/spec/frontend/design_management/components/design_scaler_spec.js index 8a123b2d1e5..095c070e5e8 100644 --- a/spec/frontend/design_management/components/design_scaler_spec.js +++ b/spec/frontend/design_management/components/design_scaler_spec.js @@ -13,7 +13,11 @@ describe('Design management design scaler component', () => { const setScale = (scale) => wrapper.vm.setScale(scale); const createComponent = () => { - wrapper = shallowMount(DesignScaler); + wrapper = shallowMount(DesignScaler, { + propsData: { + maxScale: 2, + }, + }); }; beforeEach(() => { @@ -61,6 +65,18 @@ describe('Design management design scaler component', () => { expect(wrapper.emitted('scale')).toEqual([[1.2]]); }); + it('computes & increments correct stepSize based on maxScale', async () => { + wrapper.setProps({ maxScale: 11 }); + + await wrapper.vm.$nextTick(); + + getIncreaseScaleButton().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted().scale[0][0]).toBe(3); + }); + describe('when `scale` value is 1', () => { it('disables the "reset" button', () => { const resetButton = getResetScaleButton(); @@ -77,7 +93,7 @@ describe('Design management design scaler component', () => { }); }); - describe('when `scale` value is 2 (maximum)', () => { + describe('when `scale` value is maximum', () => { beforeEach(async () => { setScale(2); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap index 637f22457c4..67e4a82787c 100644 --- a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap +++ b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap @@ -3,10 +3,14 @@ exports[`Design management design version dropdown component renders design version dropdown button 1`] = ` <gl-dropdown-stub category="primary" + clearalltext="Clear all" headertext="" hideheaderborder="true" + highlighteditemstitle="Selected" + highlighteditemstitleclass="gl-px-5" issueiid="" projectpath="" + showhighlighteditemstitle="true" size="small" text="Showing latest version" variant="default" @@ -80,10 +84,14 @@ exports[`Design management design version dropdown component renders design vers exports[`Design management design version dropdown component renders design version list 1`] = ` <gl-dropdown-stub category="primary" + clearalltext="Clear all" headertext="" hideheaderborder="true" + highlighteditemstitle="Selected" + highlighteditemstitleclass="gl-px-5" issueiid="" projectpath="" + showhighlighteditemstitle="true" size="small" text="Showing latest version" variant="default" diff --git a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap index 57023c55878..3d04840b1f8 100644 --- a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap @@ -25,7 +25,9 @@ exports[`Design management design index page renders design index 1`] = ` <div class="design-scaler-wrapper gl-absolute gl-mb-6 gl-display-flex gl-justify-content-center gl-align-items-center" > - <design-scaler-stub /> + <design-scaler-stub + maxscale="2" + /> </div> </div> @@ -186,7 +188,9 @@ exports[`Design management design index page with error GlAlert is rendered in c <div class="design-scaler-wrapper gl-absolute gl-mb-6 gl-display-flex gl-justify-content-center gl-align-items-center" > - <design-scaler-stub /> + <design-scaler-stub + maxscale="2" + /> </div> </div> diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js index 1332e872246..6ce384b4869 100644 --- a/spec/frontend/design_management/pages/design/index_spec.js +++ b/spec/frontend/design_management/pages/design/index_spec.js @@ -390,28 +390,13 @@ describe('Design management design index page', () => { ); }); - describe('with usage_data_design_action enabled', () => { - it('tracks design view service ping', () => { - createComponent( - { loading: true }, - { - provide: { - glFeatures: { usageDataDesignAction: true }, - }, - }, - ); - expect(Api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); - expect(Api.trackRedisHllUserEvent).toHaveBeenCalledWith( - DESIGN_SERVICE_PING_EVENT_TYPES.DESIGN_ACTION, - ); - }); - }); + it('tracks design view service ping', () => { + createComponent({ loading: true }); - describe('with usage_data_design_action disabled', () => { - it("doesn't track design view service ping", () => { - createComponent({ loading: true }); - expect(Api.trackRedisHllUserEvent).toHaveBeenCalledTimes(0); - }); + expect(Api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); + expect(Api.trackRedisHllUserEvent).toHaveBeenCalledWith( + DESIGN_SERVICE_PING_EVENT_TYPES.DESIGN_ACTION, + ); }); }); }); diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index 95cb1ac943c..ce79feae2e7 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -338,6 +338,13 @@ describe('Design management index page', () => { __typename: 'DesignVersion', id: expect.anything(), sha: expect.anything(), + createdAt: '', + author: { + __typename: 'UserCore', + id: expect.anything(), + name: '', + avatarUrl: '', + }, }, }, }, @@ -623,6 +630,16 @@ describe('Design management index page', () => { expect(mockMutate).not.toHaveBeenCalled(); }); + it('does not upload designs if designs wrapper is destroyed', () => { + findDesignsWrapper().trigger('mouseenter'); + + wrapper.destroy(); + + document.dispatchEvent(event); + + expect(mockMutate).not.toHaveBeenCalled(); + }); + describe('when designs wrapper is hovered', () => { let realDateNow; const today = () => new Date('2020-12-25'); diff --git a/spec/frontend/design_management/utils/design_management_utils_spec.js b/spec/frontend/design_management/utils/design_management_utils_spec.js index 5b7f99e9d96..dc6056badb9 100644 --- a/spec/frontend/design_management/utils/design_management_utils_spec.js +++ b/spec/frontend/design_management/utils/design_management_utils_spec.js @@ -101,7 +101,13 @@ describe('optimistic responses', () => { discussions: { __typename: 'DesignDiscussion', nodes: [] }, versions: { __typename: 'DesignVersionConnection', - nodes: { __typename: 'DesignVersion', id: -1, sha: -1 }, + nodes: { + __typename: 'DesignVersion', + id: expect.anything(), + sha: expect.anything(), + createdAt: '', + author: { __typename: 'UserCore', avatarUrl: '', name: '', id: expect.anything() }, + }, }, }, ], diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index 1464dd84666..9dc82bbdc93 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -183,7 +183,7 @@ describe('diffs/components/app', () => { it('displays loading icon on batch loading', () => { createComponent({}, ({ state }) => { - state.diffs.isBatchLoading = true; + state.diffs.batchLoadingState = 'loading'; }); expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); @@ -705,24 +705,4 @@ describe('diffs/components/app', () => { ); }); }); - - describe('diff file tree is aware of review bar', () => { - it('it does not have review-bar-visible class when review bar is not visible', () => { - createComponent({}, ({ state }) => { - state.diffs.diffFiles = [{ file_hash: '111', file_path: '111.js' }]; - }); - - expect(wrapper.find('.js-diff-tree-list').exists()).toBe(true); - expect(wrapper.find('.js-diff-tree-list.review-bar-visible').exists()).toBe(false); - }); - - it('it does have review-bar-visible class when review bar is visible', () => { - createComponent({}, ({ state }) => { - state.diffs.diffFiles = [{ file_hash: '111', file_path: '111.js' }]; - state.batchComments.drafts = ['draft message']; - }); - - expect(wrapper.find('.js-diff-tree-list.review-bar-visible').exists()).toBe(true); - }); - }); }); diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js index 3dec56f2fe3..feb7118744b 100644 --- a/spec/frontend/diffs/components/diff_file_spec.js +++ b/spec/frontend/diffs/components/diff_file_spec.js @@ -242,32 +242,20 @@ describe('DiffFile', () => { }); it.each` - loggedIn | featureOn | bool - ${true} | ${true} | ${true} - ${false} | ${true} | ${false} - ${true} | ${false} | ${false} - ${false} | ${false} | ${false} - `( - 'should be $bool when { userIsLoggedIn: $loggedIn, featureEnabled: $featureOn }', - ({ loggedIn, featureOn, bool }) => { - setLoggedIn(loggedIn); - - ({ wrapper } = createComponent({ - options: { - provide: { - glFeatures: { - localFileReviews: featureOn, - }, - }, - }, - props: { - file: store.state.diffs.diffFiles[0], - }, - })); + loggedIn | bool + ${true} | ${true} + ${false} | ${false} + `('should be $bool when { userIsLoggedIn: $loggedIn }', ({ loggedIn, bool }) => { + setLoggedIn(loggedIn); + + ({ wrapper } = createComponent({ + props: { + file: store.state.diffs.diffFiles[0], + }, + })); - expect(wrapper.vm.showLocalFileReviews).toBe(bool); - }, - ); + expect(wrapper.vm.showLocalFileReviews).toBe(bool); + }); }); }); diff --git a/spec/frontend/diffs/create_diffs_store.js b/spec/frontend/diffs/create_diffs_store.js index e6a8b7a72ae..307ebdaa4ac 100644 --- a/spec/frontend/diffs/create_diffs_store.js +++ b/spec/frontend/diffs/create_diffs_store.js @@ -9,6 +9,12 @@ Vue.use(Vuex); export default function createDiffsStore() { return new Vuex.Store({ modules: { + page: { + namespaced: true, + state: { + activeTab: 'notes', + }, + }, diffs: diffsModule(), notes: notesModule(), batchComments: batchCommentsModule(), diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index 6d005b868a9..b35abc9da02 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -186,15 +186,16 @@ describe('DiffsStoreActions', () => { {}, { endpointBatch, diffViewType: 'inline' }, [ - { type: types.SET_BATCH_LOADING, payload: true }, + { type: types.SET_BATCH_LOADING_STATE, payload: 'loading' }, { type: types.SET_RETRIEVING_BATCHES, payload: true }, { type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: res1.diff_files } }, - { type: types.SET_BATCH_LOADING, payload: false }, + { type: types.SET_BATCH_LOADING_STATE, payload: 'loaded' }, { type: types.VIEW_DIFF_FILE, payload: 'test' }, { type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: res2.diff_files } }, - { type: types.SET_BATCH_LOADING, payload: false }, + { type: types.SET_BATCH_LOADING_STATE, payload: 'loaded' }, { type: types.VIEW_DIFF_FILE, payload: 'test2' }, { type: types.SET_RETRIEVING_BATCHES, payload: false }, + { type: types.SET_BATCH_LOADING_STATE, payload: 'error' }, ], [{ type: 'startRenderDiffsQueue' }, { type: 'startRenderDiffsQueue' }], done, diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js index b549ca42634..fc9ba223d5a 100644 --- a/spec/frontend/diffs/store/mutations_spec.js +++ b/spec/frontend/diffs/store/mutations_spec.js @@ -31,13 +31,13 @@ describe('DiffsStoreMutations', () => { }); }); - describe('SET_BATCH_LOADING', () => { + describe('SET_BATCH_LOADING_STATE', () => { it('should set loading state', () => { const state = {}; - mutations[types.SET_BATCH_LOADING](state, false); + mutations[types.SET_BATCH_LOADING_STATE](state, false); - expect(state.isBatchLoading).toEqual(false); + expect(state.batchLoadingState).toEqual(false); }); }); diff --git a/spec/frontend/diffs/utils/preferences_spec.js b/spec/frontend/diffs/utils/preferences_spec.js deleted file mode 100644 index 2dcc71dc188..00000000000 --- a/spec/frontend/diffs/utils/preferences_spec.js +++ /dev/null @@ -1,32 +0,0 @@ -import Cookies from 'js-cookie'; -import { - DIFF_FILE_BY_FILE_COOKIE_NAME, - DIFF_VIEW_FILE_BY_FILE, - DIFF_VIEW_ALL_FILES, -} from '~/diffs/constants'; -import { fileByFile } from '~/diffs/utils/preferences'; - -describe('diffs preferences', () => { - describe('fileByFile', () => { - afterEach(() => { - Cookies.remove(DIFF_FILE_BY_FILE_COOKIE_NAME); - }); - - it.each` - result | preference | cookie - ${true} | ${false} | ${DIFF_VIEW_FILE_BY_FILE} - ${false} | ${true} | ${DIFF_VIEW_ALL_FILES} - ${true} | ${false} | ${DIFF_VIEW_FILE_BY_FILE} - ${false} | ${true} | ${DIFF_VIEW_ALL_FILES} - ${false} | ${false} | ${DIFF_VIEW_ALL_FILES} - ${true} | ${true} | ${DIFF_VIEW_FILE_BY_FILE} - `( - 'should return $result when { preference: $preference, cookie: $cookie }', - ({ result, preference, cookie }) => { - Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, cookie); - - expect(fileByFile(preference)).toBe(result); - }, - ); - }); -}); diff --git a/spec/frontend/dropzone_input_spec.js b/spec/frontend/dropzone_input_spec.js index 5e6ccbd7cda..acf7d0780cd 100644 --- a/spec/frontend/dropzone_input_spec.js +++ b/spec/frontend/dropzone_input_spec.js @@ -1,9 +1,12 @@ +import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import mock from 'xhr-mock'; import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'spec/test_constants'; import PasteMarkdownTable from '~/behaviors/markdown/paste_markdown_table'; import dropzoneInput from '~/dropzone_input'; +import axios from '~/lib/utils/axios_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; const TEST_FILE = new File([], 'somefile.jpg'); TEST_FILE.upload = {}; @@ -29,6 +32,16 @@ describe('dropzone_input', () => { }); describe('handlePaste', () => { + const triggerPasteEvent = (clipboardData = {}) => { + const event = $.Event('paste'); + const origEvent = new Event('paste'); + + origEvent.clipboardData = clipboardData; + event.originalEvent = origEvent; + + $('.js-gfm-input').trigger(event); + }; + beforeEach(() => { loadFixtures('issues/new-issue.html'); @@ -38,24 +51,39 @@ describe('dropzone_input', () => { }); it('pastes Markdown tables', () => { - const event = $.Event('paste'); - const origEvent = new Event('paste'); + jest.spyOn(PasteMarkdownTable.prototype, 'isTable'); + jest.spyOn(PasteMarkdownTable.prototype, 'convertToTableMarkdown'); - origEvent.clipboardData = { + triggerPasteEvent({ types: ['text/plain', 'text/html'], getData: () => '<table><tr><td>Hello World</td></tr></table>', items: [], - }; - event.originalEvent = origEvent; - - jest.spyOn(PasteMarkdownTable.prototype, 'isTable'); - jest.spyOn(PasteMarkdownTable.prototype, 'convertToTableMarkdown'); - - $('.js-gfm-input').trigger(event); + }); expect(PasteMarkdownTable.prototype.isTable).toHaveBeenCalled(); expect(PasteMarkdownTable.prototype.convertToTableMarkdown).toHaveBeenCalled(); }); + + it('passes truncated long filename to post request', async () => { + const axiosMock = new MockAdapter(axios); + const longFileName = 'a'.repeat(300); + + triggerPasteEvent({ + types: ['text/plain', 'text/html', 'text/rtf', 'Files'], + getData: () => longFileName, + items: [ + { + kind: 'file', + type: 'image/png', + getAsFile: () => new Blob(), + }, + ], + }); + + axiosMock.onPost().reply(httpStatusCodes.OK, { link: { markdown: 'foo' } }); + await waitForPromises(); + expect(axiosMock.history.post[0].data.get('file').name).toHaveLength(246); + }); }); describe('shows error message', () => { diff --git a/spec/frontend/emoji/index_spec.js b/spec/frontend/emoji/index_spec.js index 1e6f5483160..9652c513671 100644 --- a/spec/frontend/emoji/index_spec.js +++ b/spec/frontend/emoji/index_spec.js @@ -9,6 +9,7 @@ import isEmojiUnicodeSupported, { isHorceRacingSkinToneComboEmoji, isPersonZwjEmoji, } from '~/emoji/support/is_emoji_unicode_supported'; +import { sanitize } from '~/lib/dompurify'; const emptySupportMap = { personZwj: false, @@ -379,7 +380,7 @@ describe('emoji', () => { describe('searchEmoji', () => { const emojiFixture = Object.keys(mockEmojiData).reduce((acc, k) => { const { name, e, u, d } = mockEmojiData[k]; - acc[k] = { name, e, u, d }; + acc[k] = { name, e: sanitize(e), u, d }; return acc; }, {}); @@ -397,6 +398,7 @@ describe('emoji', () => { 'heart', 'custard', 'star', + 'xss', ].map((name) => { return { emoji: emojiFixture[name], @@ -620,4 +622,13 @@ describe('emoji', () => { expect(sortEmoji(scoredItems)).toEqual(expected); }); }); + + describe('sanitize emojis', () => { + it('should return sanitized emoji', () => { + expect(getEmojiInfo('xss')).toEqual({ + ...mockEmojiData.xss, + e: '<img src="x">', + }); + }); + }); }); diff --git a/spec/frontend/emoji/support/unicode_support_map_spec.js b/spec/frontend/emoji/support/unicode_support_map_spec.js index 945e804a9fa..37f74db30b5 100644 --- a/spec/frontend/emoji/support/unicode_support_map_spec.js +++ b/spec/frontend/emoji/support/unicode_support_map_spec.js @@ -8,14 +8,14 @@ describe('Unicode Support Map', () => { const stringSupportMap = 'stringSupportMap'; beforeEach(() => { - jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockImplementation(() => {}); + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockImplementation(() => {}); jest.spyOn(JSON, 'parse').mockImplementation(() => {}); jest.spyOn(JSON, 'stringify').mockReturnValue(stringSupportMap); }); describe('if isLocalStorageAvailable is `true`', () => { beforeEach(() => { - jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(true); + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true); getUnicodeSupportMap(); }); @@ -38,7 +38,7 @@ describe('Unicode Support Map', () => { describe('if isLocalStorageAvailable is `false`', () => { beforeEach(() => { - jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(false); + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false); getUnicodeSupportMap(); }); diff --git a/spec/frontend/environments/edit_environment_spec.js b/spec/frontend/environments/edit_environment_spec.js index 3e7f5dd5ff4..2c8c054ccbd 100644 --- a/spec/frontend/environments/edit_environment_spec.js +++ b/spec/frontend/environments/edit_environment_spec.js @@ -15,15 +15,12 @@ const DEFAULT_OPTS = { projectEnvironmentsPath: '/projects/environments', updateEnvironmentPath: '/proejcts/environments/1', }, - propsData: { environment: { name: 'foo', externalUrl: 'https://foo.example.com' } }, + propsData: { environment: { id: '0', name: 'foo', external_url: 'https://foo.example.com' } }, }; describe('~/environments/components/edit.vue', () => { let wrapper; let mock; - let name; - let url; - let form; const createWrapper = (opts = {}) => mountExtended(EditEnvironment, { @@ -34,9 +31,6 @@ describe('~/environments/components/edit.vue', () => { beforeEach(() => { mock = new MockAdapter(axios); wrapper = createWrapper(); - name = wrapper.findByLabelText('Name'); - url = wrapper.findByLabelText('External URL'); - form = wrapper.findByRole('form', { name: 'Edit environment' }); }); afterEach(() => { @@ -44,19 +38,22 @@ describe('~/environments/components/edit.vue', () => { wrapper.destroy(); }); + const findNameInput = () => wrapper.findByLabelText('Name'); + const findExternalUrlInput = () => wrapper.findByLabelText('External URL'); + const findForm = () => wrapper.findByRole('form', { name: 'Edit environment' }); + const showsLoading = () => wrapper.find(GlLoadingIcon).exists(); const submitForm = async (expected, response) => { mock .onPut(DEFAULT_OPTS.provide.updateEnvironmentPath, { - name: expected.name, external_url: expected.url, + id: '0', }) .reply(...response); - await name.setValue(expected.name); - await url.setValue(expected.url); + await findExternalUrlInput().setValue(expected.url); - await form.trigger('submit'); + await findForm().trigger('submit'); await waitForPromises(); }; @@ -65,18 +62,8 @@ describe('~/environments/components/edit.vue', () => { expect(header.exists()).toBe(true); }); - it.each` - input | value - ${() => name} | ${'test'} - ${() => url} | ${'https://example.org'} - `('it changes the value of the input to $value', async ({ input, value }) => { - await input().setValue(value); - - expect(input().element.value).toBe(value); - }); - it('shows loader after form is submitted', async () => { - const expected = { name: 'test', url: 'https://google.ca' }; + const expected = { url: 'https://google.ca' }; expect(showsLoading()).toBe(false); @@ -86,7 +73,7 @@ describe('~/environments/components/edit.vue', () => { }); it('submits the updated environment on submit', async () => { - const expected = { name: 'test', url: 'https://google.ca' }; + const expected = { url: 'https://google.ca' }; await submitForm(expected, [200, { path: '/test' }]); @@ -94,11 +81,24 @@ describe('~/environments/components/edit.vue', () => { }); it('shows errors on error', async () => { - const expected = { name: 'test', url: 'https://google.ca' }; + const expected = { url: 'https://google.ca' }; - await submitForm(expected, [400, { message: ['name taken'] }]); + await submitForm(expected, [400, { message: ['uh oh!'] }]); - expect(createFlash).toHaveBeenCalledWith({ message: 'name taken' }); + expect(createFlash).toHaveBeenCalledWith({ message: 'uh oh!' }); expect(showsLoading()).toBe(false); }); + + it('renders a disabled "Name" field', () => { + const nameInput = findNameInput(); + + expect(nameInput.attributes().disabled).toBe('disabled'); + expect(nameInput.element.value).toBe('foo'); + }); + + it('renders an "External URL" field', () => { + const urlInput = findExternalUrlInput(); + + expect(urlInput.element.value).toBe('https://foo.example.com'); + }); }); diff --git a/spec/frontend/environments/environment_form_spec.js b/spec/frontend/environments/environment_form_spec.js index ed8fda71dab..f1af08bcf32 100644 --- a/spec/frontend/environments/environment_form_spec.js +++ b/spec/frontend/environments/environment_form_spec.js @@ -102,4 +102,52 @@ describe('~/environments/components/form.vue', () => { wrapper = createWrapper({ loading: true }); expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); + describe('when a new environment is being created', () => { + beforeEach(() => { + wrapper = createWrapper({ + environment: { + name: '', + externalUrl: '', + }, + }); + }); + + it('renders an enabled "Name" field', () => { + const nameInput = wrapper.findByLabelText('Name'); + + expect(nameInput.attributes().disabled).toBeUndefined(); + expect(nameInput.element.value).toBe(''); + }); + + it('renders an "External URL" field', () => { + const urlInput = wrapper.findByLabelText('External URL'); + + expect(urlInput.element.value).toBe(''); + }); + }); + + describe('when an existing environment is being edited', () => { + beforeEach(() => { + wrapper = createWrapper({ + environment: { + id: 1, + name: 'test', + externalUrl: 'https://example.com', + }, + }); + }); + + it('renders a disabled "Name" field', () => { + const nameInput = wrapper.findByLabelText('Name'); + + expect(nameInput.attributes().disabled).toBe('disabled'); + expect(nameInput.element.value).toBe('test'); + }); + + it('renders an "External URL" field', () => { + const urlInput = wrapper.findByLabelText('External URL'); + + expect(urlInput.element.value).toBe('https://example.com'); + }); + }); }); diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js index a568a7d5396..b930259149f 100644 --- a/spec/frontend/environments/environment_item_spec.js +++ b/spec/frontend/environments/environment_item_spec.js @@ -31,7 +31,6 @@ describe('Environment item', () => { factory({ propsData: { model: environment, - canReadEnvironment: true, tableData, }, }); @@ -135,7 +134,6 @@ describe('Environment item', () => { factory({ propsData: { model: environmentWithoutDeployable, - canReadEnvironment: true, tableData, }, }); @@ -161,7 +159,6 @@ describe('Environment item', () => { factory({ propsData: { model: environmentWithoutUpcomingDeployment, - canReadEnvironment: true, tableData, }, }); @@ -177,7 +174,6 @@ describe('Environment item', () => { factory({ propsData: { model: environment, - canReadEnvironment: true, tableData, shouldShowAutoStopDate: true, }, @@ -205,7 +201,6 @@ describe('Environment item', () => { ...environment, auto_stop_at: futureDate, }, - canReadEnvironment: true, tableData, shouldShowAutoStopDate: true, }, @@ -241,7 +236,6 @@ describe('Environment item', () => { ...environment, auto_stop_at: pastDate, }, - canReadEnvironment: true, tableData, shouldShowAutoStopDate: true, }, @@ -360,7 +354,6 @@ describe('Environment item', () => { factory({ propsData: { model: folder, - canReadEnvironment: true, tableData, }, }); diff --git a/spec/frontend/environments/environment_table_spec.js b/spec/frontend/environments/environment_table_spec.js index 71426ee5170..1851163ac68 100644 --- a/spec/frontend/environments/environment_table_spec.js +++ b/spec/frontend/environments/environment_table_spec.js @@ -28,7 +28,6 @@ describe('Environment table', () => { factory({ propsData: { environments: [folder], - canReadEnvironment: true, ...eeOnlyProps, }, }); @@ -50,7 +49,6 @@ describe('Environment table', () => { await factory({ propsData: { environments: [mockItem], - canReadEnvironment: true, userCalloutsPath: '/callouts', lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', helpCanaryDeploymentsPath: 'help/canary-deployments', @@ -78,7 +76,6 @@ describe('Environment table', () => { propsData: { environments: [mockItem], canCreateDeployment: false, - canReadEnvironment: true, userCalloutsPath: '/callouts', lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', helpCanaryDeploymentsPath: 'help/canary-deployments', @@ -114,7 +111,6 @@ describe('Environment table', () => { propsData: { environments: [mockItem], canCreateDeployment: false, - canReadEnvironment: true, userCalloutsPath: '/callouts', lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', helpCanaryDeploymentsPath: 'help/canary-deployments', @@ -151,7 +147,6 @@ describe('Environment table', () => { factory({ propsData: { environments: [mockItem], - canReadEnvironment: true, userCalloutsPath: '/callouts', lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', helpCanaryDeploymentsPath: 'help/canary-deployments', @@ -179,7 +174,6 @@ describe('Environment table', () => { propsData: { environments: [mockItem], canCreateDeployment: false, - canReadEnvironment: true, userCalloutsPath: '/callouts', lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', helpCanaryDeploymentsPath: 'help/canary-deployments', @@ -230,7 +224,6 @@ describe('Environment table', () => { factory({ propsData: { environments: mockItems, - canReadEnvironment: true, ...eeOnlyProps, }, }); @@ -296,7 +289,6 @@ describe('Environment table', () => { factory({ propsData: { environments: mockItems, - canReadEnvironment: true, ...eeOnlyProps, }, }); @@ -335,7 +327,6 @@ describe('Environment table', () => { factory({ propsData: { environments: mockItems, - canReadEnvironment: true, ...eeOnlyProps, }, }); @@ -364,7 +355,6 @@ describe('Environment table', () => { factory({ propsData: { environments: mockItems, - canReadEnvironment: true, ...eeOnlyProps, }, }); @@ -415,7 +405,6 @@ describe('Environment table', () => { factory({ propsData: { environments: mockItems, - canReadEnvironment: true, ...eeOnlyProps, }, }); diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js index dc176001943..cd05ecbfb53 100644 --- a/spec/frontend/environments/environments_app_spec.js +++ b/spec/frontend/environments/environments_app_spec.js @@ -1,4 +1,4 @@ -import { GlTabs, GlAlert } from '@gitlab/ui'; +import { GlTabs } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -7,9 +7,7 @@ import DeployBoard from '~/environments/components/deploy_board.vue'; import EmptyState from '~/environments/components/empty_state.vue'; import EnableReviewAppModal from '~/environments/components/enable_review_app_modal.vue'; import EnvironmentsApp from '~/environments/components/environments_app.vue'; -import { ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME } from '~/environments/constants'; import axios from '~/lib/utils/axios_utils'; -import { setCookie, getCookie, removeCookie } from '~/lib/utils/common_utils'; import * as urlUtils from '~/lib/utils/url_utility'; import { environment, folder } from './mock_data'; @@ -20,7 +18,6 @@ describe('Environment', () => { const mockData = { endpoint: 'environments.json', canCreateEnvironment: true, - canReadEnvironment: true, newEnvironmentPath: 'environments/new', helpPagePath: 'help', userCalloutsPath: '/callouts', @@ -50,7 +47,6 @@ describe('Environment', () => { const findNewEnvironmentButton = () => wrapper.findByTestId('new-environment'); const findEnvironmentsTabAvailable = () => wrapper.find('.js-environments-tab-available > a'); const findEnvironmentsTabStopped = () => wrapper.find('.js-environments-tab-stopped > a'); - const findSurveyAlert = () => wrapper.find(GlAlert); beforeEach(() => { mock = new MockAdapter(axios); @@ -283,49 +279,4 @@ describe('Environment', () => { expect(wrapper.findComponent(GlTabs).attributes('value')).toBe('1'); }); }); - - describe('survey alert', () => { - beforeEach(async () => { - mockRequest(200, { environments: [] }); - await createWrapper(true); - }); - - afterEach(() => { - removeCookie(ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME); - }); - - describe('when the user has not dismissed the alert', () => { - it('shows the alert', () => { - expect(findSurveyAlert().exists()).toBe(true); - }); - - describe('when the user dismisses the alert', () => { - beforeEach(() => { - findSurveyAlert().vm.$emit('dismiss'); - }); - - it('hides the alert', () => { - expect(findSurveyAlert().exists()).toBe(false); - }); - - it('persists the dismisal using a cookie', () => { - const cookieValue = getCookie(ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME); - - expect(cookieValue).toBe('true'); - }); - }); - }); - - describe('when the user has previously dismissed the alert', () => { - beforeEach(async () => { - setCookie(ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME, 'true'); - - await createWrapper(true); - }); - - it('does not show the alert', () => { - expect(findSurveyAlert().exists()).toBe(false); - }); - }); - }); }); diff --git a/spec/frontend/environments/environments_detail_header_spec.js b/spec/frontend/environments/environments_detail_header_spec.js index 6334060c736..305e7385b43 100644 --- a/spec/frontend/environments/environments_detail_header_spec.js +++ b/spec/frontend/environments/environments_detail_header_spec.js @@ -44,7 +44,6 @@ describe('Environments detail header component', () => { TimeAgo, }, propsData: { - canReadEnvironment: false, canAdminEnvironment: false, canUpdateEnvironment: false, canStopEnvironment: false, @@ -60,7 +59,7 @@ describe('Environments detail header component', () => { describe('default state with minimal access', () => { beforeEach(() => { - createWrapper({ props: { environment: createEnvironment() } }); + createWrapper({ props: { environment: createEnvironment({ externalUrl: null }) } }); }); it('displays the environment name', () => { @@ -164,7 +163,6 @@ describe('Environments detail header component', () => { createWrapper({ props: { environment: createEnvironment({ hasTerminals: true, externalUrl }), - canReadEnvironment: true, }, }); }); @@ -178,8 +176,7 @@ describe('Environments detail header component', () => { beforeEach(() => { createWrapper({ props: { - environment: createEnvironment(), - canReadEnvironment: true, + environment: createEnvironment({ metricsUrl: 'my metrics url' }), metricsPath, }, }); @@ -195,7 +192,6 @@ describe('Environments detail header component', () => { createWrapper({ props: { environment: createEnvironment(), - canReadEnvironment: true, canAdminEnvironment: true, canStopEnvironment: true, canUpdateEnvironment: true, diff --git a/spec/frontend/environments/environments_folder_view_spec.js b/spec/frontend/environments/environments_folder_view_spec.js index e4661d27872..72a7449f24e 100644 --- a/spec/frontend/environments/environments_folder_view_spec.js +++ b/spec/frontend/environments/environments_folder_view_spec.js @@ -11,7 +11,6 @@ describe('Environments Folder View', () => { const mockData = { endpoint: 'environments.json', folderName: 'review', - canReadEnvironment: true, cssContainerClass: 'container', userCalloutsPath: '/callouts', lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', diff --git a/spec/frontend/environments/folder/environments_folder_view_spec.js b/spec/frontend/environments/folder/environments_folder_view_spec.js index d02ed8688c6..9eb57b2682f 100644 --- a/spec/frontend/environments/folder/environments_folder_view_spec.js +++ b/spec/frontend/environments/folder/environments_folder_view_spec.js @@ -14,7 +14,6 @@ describe('Environments Folder View', () => { const mockData = { endpoint: 'environments.json', folderName: 'review', - canReadEnvironment: true, cssContainerClass: 'container', userCalloutsPath: '/callouts', lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', diff --git a/spec/frontend/error_tracking_settings/components/app_spec.js b/spec/frontend/error_tracking_settings/components/app_spec.js index e0be81b3899..30541ba68a5 100644 --- a/spec/frontend/error_tracking_settings/components/app_spec.js +++ b/spec/frontend/error_tracking_settings/components/app_spec.js @@ -1,6 +1,9 @@ +import { GlFormRadioGroup, GlFormRadio } from '@gitlab/ui'; import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import Vuex from 'vuex'; import { TEST_HOST } from 'helpers/test_constants'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; 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'; @@ -14,20 +17,31 @@ describe('error tracking settings app', () => { 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, - }, - }); + wrapper = extendedWrapper( + shallowMount(ErrorTrackingSettings, { + localVue, + store, // Override the imported store + propsData: { + initialEnabled: 'true', + initialIntegrated: 'false', + initialApiHost: TEST_HOST, + initialToken: 'someToken', + initialProject: null, + listProjectsEndpoint: TEST_HOST, + operationsSettingsEndpoint: TEST_HOST, + }, + }), + ); } + const findBackendSettingsSection = () => wrapper.findByTestId('tracking-backend-settings'); + const findBackendSettingsRadioGroup = () => + findBackendSettingsSection().findComponent(GlFormRadioGroup); + const findBackendSettingsRadioButtons = () => + findBackendSettingsRadioGroup().findAllComponents(GlFormRadio); + const findElementWithText = (wrappers, text) => wrappers.filter((item) => item.text() === text); + const findSentrySettings = () => wrapper.findByTestId('sentry-setting-form'); + beforeEach(() => { store = createStore(); @@ -62,4 +76,46 @@ describe('error tracking settings app', () => { }); }); }); + + describe('tracking-backend settings', () => { + it('contains a form-group with the correct label', () => { + expect(findBackendSettingsSection().attributes('label')).toBe('Error tracking backend'); + }); + + it('contains a radio group', () => { + expect(findBackendSettingsRadioGroup().exists()).toBe(true); + }); + + it('contains the correct radio buttons', () => { + expect(findBackendSettingsRadioButtons()).toHaveLength(2); + + expect(findElementWithText(findBackendSettingsRadioButtons(), 'Sentry')).toHaveLength(1); + expect(findElementWithText(findBackendSettingsRadioButtons(), 'GitLab')).toHaveLength(1); + }); + + it('toggles the sentry-settings section when sentry is selected as a tracking-backend', async () => { + expect(findSentrySettings().exists()).toBe(true); + + // set the "integrated" setting to "true" + findBackendSettingsRadioGroup().vm.$emit('change', true); + + await nextTick(); + + expect(findSentrySettings().exists()).toBe(false); + }); + + it.each([true, false])( + 'calls the `updateIntegrated` action when the setting changes to `%s`', + (integrated) => { + jest.spyOn(store, 'dispatch').mockImplementation(); + + expect(store.dispatch).toHaveBeenCalledTimes(0); + + findBackendSettingsRadioGroup().vm.$emit('change', integrated); + + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(store.dispatch).toHaveBeenCalledWith('updateIntegrated', integrated); + }, + ); + }); }); diff --git a/spec/frontend/error_tracking_settings/mock.js b/spec/frontend/error_tracking_settings/mock.js index e64a6d1fe14..b2d7a912518 100644 --- a/spec/frontend/error_tracking_settings/mock.js +++ b/spec/frontend/error_tracking_settings/mock.js @@ -42,6 +42,7 @@ export const sampleBackendProject = { export const sampleFrontendSettings = { apiHost: 'apiHost', enabled: false, + integrated: false, token: 'token', selectedProject: { slug: normalizedProject.slug, @@ -54,6 +55,7 @@ export const sampleFrontendSettings = { export const transformedSettings = { api_host: 'apiHost', enabled: false, + integrated: false, token: 'token', project: { slug: normalizedProject.slug, @@ -71,6 +73,7 @@ export const defaultProps = { export const initialEmptyState = { apiHost: '', enabled: false, + integrated: false, project: null, token: '', listProjectsEndpoint: TEST_HOST, @@ -80,6 +83,7 @@ export const initialEmptyState = { export const initialPopulatedState = { apiHost: 'apiHost', enabled: true, + integrated: true, project: JSON.stringify(projectList[0]), token: 'token', listProjectsEndpoint: TEST_HOST, diff --git a/spec/frontend/error_tracking_settings/store/actions_spec.js b/spec/frontend/error_tracking_settings/store/actions_spec.js index 281db7d9686..1b9be042dd4 100644 --- a/spec/frontend/error_tracking_settings/store/actions_spec.js +++ b/spec/frontend/error_tracking_settings/store/actions_spec.js @@ -202,5 +202,11 @@ describe('error tracking settings actions', () => { done, ); }); + + it.each([true, false])('should set the `integrated` flag to `%s`', async (payload) => { + await testAction(actions.updateIntegrated, payload, state, [ + { type: types.UPDATE_INTEGRATED, payload }, + ]); + }); }); }); diff --git a/spec/frontend/error_tracking_settings/store/mutation_spec.js b/spec/frontend/error_tracking_settings/store/mutation_spec.js index 78fd56904b3..ecf1c91c08a 100644 --- a/spec/frontend/error_tracking_settings/store/mutation_spec.js +++ b/spec/frontend/error_tracking_settings/store/mutation_spec.js @@ -25,6 +25,7 @@ describe('error tracking settings mutations', () => { expect(state.apiHost).toEqual(''); expect(state.enabled).toEqual(false); + expect(state.integrated).toEqual(false); expect(state.selectedProject).toEqual(null); expect(state.token).toEqual(''); expect(state.listProjectsEndpoint).toEqual(TEST_HOST); @@ -38,6 +39,7 @@ describe('error tracking settings mutations', () => { expect(state.apiHost).toEqual('apiHost'); expect(state.enabled).toEqual(true); + expect(state.integrated).toEqual(true); expect(state.selectedProject).toEqual(projectList[0]); expect(state.token).toEqual('token'); expect(state.listProjectsEndpoint).toEqual(TEST_HOST); @@ -78,5 +80,11 @@ describe('error tracking settings mutations', () => { expect(state.connectSuccessful).toBe(false); expect(state.connectError).toBe(false); }); + + it.each([true, false])('should update `integrated` to `%s`', (integrated) => { + mutations[types.UPDATE_INTEGRATED](state, integrated); + + expect(state.integrated).toBe(integrated); + }); }); }); diff --git a/spec/frontend/error_tracking_settings/utils_spec.js b/spec/frontend/error_tracking_settings/utils_spec.js index 4b144f7daf1..61e75cdc45e 100644 --- a/spec/frontend/error_tracking_settings/utils_spec.js +++ b/spec/frontend/error_tracking_settings/utils_spec.js @@ -11,12 +11,14 @@ describe('error tracking settings utils', () => { const emptyFrontendSettingsObject = { apiHost: '', enabled: false, + integrated: false, token: '', selectedProject: null, }; const transformedEmptySettingsObject = { api_host: null, enabled: false, + integrated: false, token: null, project: null, }; diff --git a/spec/frontend/experimentation/utils_spec.js b/spec/frontend/experimentation/utils_spec.js index 2ba8c65a252..999bed1ffbd 100644 --- a/spec/frontend/experimentation/utils_spec.js +++ b/spec/frontend/experimentation/utils_spec.js @@ -37,6 +37,50 @@ describe('experiment Utilities', () => { }); }); + describe('getAllExperimentContexts', () => { + const schema = TRACKING_CONTEXT_SCHEMA; + let origGon; + + beforeEach(() => { + origGon = window.gon; + }); + + afterEach(() => { + window.gon = origGon; + }); + + it('collects all of the experiment contexts into a single array', () => { + const experiments = [ + { experiment: 'abc', variant: 'candidate' }, + { experiment: 'def', variant: 'control' }, + { experiment: 'ghi', variant: 'blue' }, + ]; + window.gon = { + experiment: experiments.reduce((collector, { experiment, variant }) => { + return { ...collector, [experiment]: { experiment, variant } }; + }, {}), + }; + + expect(experimentUtils.getAllExperimentContexts()).toEqual( + experiments.map((data) => ({ schema, data })), + ); + }); + + it('returns an empty array if there are no experiments', () => { + window.gon.experiment = {}; + + expect(experimentUtils.getAllExperimentContexts()).toEqual([]); + }); + + it('includes all additional experiment data', () => { + const experiment = 'experimentWithCustomData'; + const data = { experiment, variant: 'control', color: 'blue', style: 'rounded' }; + window.gon.experiment[experiment] = data; + + expect(experimentUtils.getAllExperimentContexts()).toContainEqual({ schema, data }); + }); + }); + describe('isExperimentVariant', () => { describe.each` gon | input | output diff --git a/spec/frontend/filtered_search/services/recent_searches_service_spec.js b/spec/frontend/filtered_search/services/recent_searches_service_spec.js index 6711ce03d40..dfa53652eb1 100644 --- a/spec/frontend/filtered_search/services/recent_searches_service_spec.js +++ b/spec/frontend/filtered_search/services/recent_searches_service_spec.js @@ -145,13 +145,13 @@ describe('RecentSearchesService', () => { let isAvailable; beforeEach(() => { - jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe'); + jest.spyOn(AccessorUtilities, 'canUseLocalStorage'); isAvailable = RecentSearchesService.isAvailable(); }); - it('should call .isLocalStorageAccessSafe', () => { - expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + it('should call .canUseLocalStorage', () => { + expect(AccessorUtilities.canUseLocalStorage).toHaveBeenCalled(); }); it('should return a boolean', () => { diff --git a/spec/frontend/fixtures/api_markdown.yml b/spec/frontend/fixtures/api_markdown.yml index b581aac6aee..1edb8cb3f41 100644 --- a/spec/frontend/fixtures/api_markdown.yml +++ b/spec/frontend/fixtures/api_markdown.yml @@ -12,14 +12,71 @@ markdown: |- * {-deleted-} * {+added+} -- name: subscript - markdown: H<sub>2</sub>O -- name: superscript - markdown: 2<sup>8</sup> = 256 - name: strike markdown: '~~del~~' - name: horizontal_rule markdown: '---' +- name: html_marks + markdown: |- + * Content editor is ~~great~~<ins>amazing</ins>. + * If the changes <abbr title="Looks good to merge">LGTM</abbr>, please <abbr title="Merge when pipeline succeeds">MWPS</abbr>. + * The English song <q>Oh I do like to be beside the seaside</q> looks like this in Hebrew: <span dir="rtl">אה, אני אוהב להיות ליד חוף הים</span>. In the computer's memory, this is stored as <bdo dir="ltr">אה, אני אוהב להיות ליד חוף הים</bdo>. + * <cite>The Scream</cite> by Edvard Munch. Painted in 1893. + * <dfn>HTML</dfn> is the standard markup language for creating web pages. + * Do not forget to buy <mark>milk</mark> today. + * This is a paragraph and <small>smaller text goes here</small>. + * The concert starts at <time datetime="20:00">20:00</time> and you'll be able to enjoy the band for at least <time datetime="PT2H30M">2h 30m</time>. + * Press <kbd>Ctrl</kbd> + <kbd>C</kbd> to copy text (Windows). + * WWF's goal is to: <q>Build a future where people live in harmony with nature.</q> We hope they succeed. + * The error occured was: <samp>Keyboard not found. Press F1 to continue.</samp> + * The area of a triangle is: 1/2 x <var>b</var> x <var>h</var>, where <var>b</var> is the base, and <var>h</var> is the vertical height. + * <ruby>漢<rt>ㄏㄢˋ</rt></ruby> + * C<sub>7</sub>H<sub>16</sub> + O<sub>2</sub> → CO<sub>2</sub> + H<sub>2</sub>O + * The **Pythagorean theorem** is often expressed as <var>a<sup>2</sup></var> + <var>b<sup>2</sup></var> = <var>c<sup>2</sup></var> +- name: div + markdown: |- + <div>plain text</div> + <div> + + just a plain ol' div, not much to _expect_! + + </div> +- name: figure + markdown: |- + <figure> + + ![Elephant at sunset](elephant-sunset.jpg) + + <figcaption>An elephant at sunset</figcaption> + </figure> + <figure> + + ![A crocodile wearing crocs](croc-crocs.jpg) + + <figcaption> + + A crocodile wearing _crocs_! + + </figcaption> + </figure> +- name: description_list + markdown: |- + <dl> + <dt>Frog</dt> + <dd>Wet green thing</dd> + <dt>Rabbit</dt> + <dd>Warm fluffy thing</dd> + <dt>Punt</dt> + <dd>Kick a ball</dd> + <dd>Take a bet</dd> + <dt>Color</dt> + <dt>Colour</dt> + <dd> + + Any hue except _white_ or **black** + + </dd> + </dl> - name: link markdown: '[GitLab](https://gitlab.com)' - name: attachment_link @@ -66,16 +123,31 @@ - name: thematic_break markdown: |- --- -- name: bullet_list +- name: bullet_list_style_1 markdown: |- * list item 1 * list item 2 * embedded list item 3 +- name: bullet_list_style_2 + markdown: |- + - list item 1 + - list item 2 + * embedded list item 3 +- name: bullet_list_style_3 + markdown: |- + + list item 1 + + list item 2 + - embedded list item 3 - name: ordered_list markdown: |- 1. list item 1 2. list item 2 3. list item 3 +- name: ordered_list_with_start_order + markdown: |- + 134. list item 1 + 135. list item 2 + 136. list item 3 - name: task_list markdown: |- * [x] hello @@ -92,6 +164,11 @@ 1. [ ] of nested 1. [x] task list 2. [ ] items +- name: ordered_task_list_with_order + markdown: |- + 4893. [x] hello + 4894. [x] world + 4895. [ ] example - name: image markdown: '![alt text](https://gitlab.com/logo.png)' - name: hard_break @@ -102,17 +179,28 @@ markdown: |- | header | header | |--------|--------| - | cell | cell | - | cell | cell | -- name: table_with_alignment - markdown: |- - | header | : header : | header : | - |--------|------------|----------| - | cell | cell | cell | - | cell | cell | cell | + | `code` | cell with **bold** | + | ~~strike~~ | cell with _italic_ | + + # content after table - name: emoji markdown: ':sparkles: :heart: :100:' - name: reference context: project_wiki markdown: |- Hi @gitlab - thank you for reporting this ~bug (#1) we hope to fix it in %1.1 as part of !1 +- name: audio + markdown: '![Sample Audio](https://gitlab.com/gitlab.mp3)' +- name: video + markdown: '![Sample Video](https://gitlab.com/gitlab.mp4)' +- name: audio_and_video_in_lists + markdown: |- + * ![Sample Audio](https://gitlab.com/1.mp3) + * ![Sample Video](https://gitlab.com/2.mp4) + + 1. ![Sample Video](https://gitlab.com/1.mp4) + 2. ![Sample Audio](https://gitlab.com/2.mp3) + + * [x] ![Sample Audio](https://gitlab.com/1.mp3) + * [x] ![Sample Audio](https://gitlab.com/2.mp3) + * [x] ![Sample Video](https://gitlab.com/3.mp4) diff --git a/spec/frontend/fixtures/freeze_period.rb b/spec/frontend/fixtures/freeze_period.rb index 09e4f969e1d..42762fa56f9 100644 --- a/spec/frontend/fixtures/freeze_period.rb +++ b/spec/frontend/fixtures/freeze_period.rb @@ -39,13 +39,4 @@ RSpec.describe 'Freeze Periods (JavaScript fixtures)' do expect(response).to be_successful end end - - describe TimeZoneHelper, '(JavaScript fixtures)' do - let(:response) { timezone_data.to_json } - - it 'api/freeze-periods/timezone_data.json' do - # Looks empty but does things - # More info: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38525/diffs#note_391048415 - end - end end diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb index e29a58f43b9..d5d6f534def 100644 --- a/spec/frontend/fixtures/runner.rb +++ b/spec/frontend/fixtures/runner.rb @@ -14,6 +14,7 @@ RSpec.describe 'Runner (JavaScript fixtures)' do let_it_be(:instance_runner) { create(:ci_runner, :instance, version: '1.0.0', revision: '123', description: 'Instance runner', ip_address: '127.0.0.1') } let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group], active: false, version: '2.0.0', revision: '456', description: 'Group runner', ip_address: '127.0.0.1') } + let_it_be(:group_runner_2) { create(:ci_runner, :group, groups: [group], active: false, version: '2.0.0', revision: '456', description: 'Group runner 2', ip_address: '127.0.0.1') } let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project], active: false, version: '2.0.0', revision: '456', description: 'Project runner', ip_address: '127.0.0.1') } query_path = 'runner/graphql/' @@ -27,14 +28,14 @@ RSpec.describe 'Runner (JavaScript fixtures)' do remove_repository(project) end - before do - sign_in(admin) - enable_admin_mode!(admin) - end - describe GraphQL::Query, type: :request do get_runners_query_name = 'get_runners.query.graphql' + before do + sign_in(admin) + enable_admin_mode!(admin) + end + let_it_be(:query) do get_graphql_query_as_string("#{query_path}#{get_runners_query_name}") end @@ -55,6 +56,11 @@ RSpec.describe 'Runner (JavaScript fixtures)' do describe GraphQL::Query, type: :request do get_runner_query_name = 'get_runner.query.graphql' + before do + sign_in(admin) + enable_admin_mode!(admin) + end + let_it_be(:query) do get_graphql_query_as_string("#{query_path}#{get_runner_query_name}") end @@ -67,4 +73,35 @@ RSpec.describe 'Runner (JavaScript fixtures)' do expect_graphql_errors_to_be_empty end end + + describe GraphQL::Query, type: :request do + get_group_runners_query_name = 'get_group_runners.query.graphql' + + let_it_be(:group_owner) { create(:user) } + + before do + group.add_owner(group_owner) + end + + let_it_be(:query) do + get_graphql_query_as_string("#{query_path}#{get_group_runners_query_name}") + end + + it "#{fixtures_path}#{get_group_runners_query_name}.json" do + post_graphql(query, current_user: group_owner, variables: { + groupFullPath: group.full_path + }) + + expect_graphql_errors_to_be_empty + end + + it "#{fixtures_path}#{get_group_runners_query_name}.paginated.json" do + post_graphql(query, current_user: group_owner, variables: { + groupFullPath: group.full_path, + first: 1 + }) + + expect_graphql_errors_to_be_empty + end + end end diff --git a/spec/frontend/fixtures/startup_css.rb b/spec/frontend/fixtures/startup_css.rb index be2ead756cf..1bd99f5cd7f 100644 --- a/spec/frontend/fixtures/startup_css.rb +++ b/spec/frontend/fixtures/startup_css.rb @@ -40,6 +40,21 @@ RSpec.describe 'Startup CSS fixtures', type: :controller do expect(response).to be_successful end + + # This Feature Flag is off by default + # This ensures that the correct css is generated + # When the feature flag is off, the general startup will capture it + # This will be removed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/339348 + it "startup_css/project-#{type}-search-ff-on.html" do + stub_feature_flags(new_header_search: true) + + get :show, params: { + namespace_id: project.namespace.to_param, + id: project + } + + expect(response).to be_successful + end end describe ProjectsController, '(Startup CSS fixtures)', type: :controller do diff --git a/spec/frontend/fixtures/static/pipeline_graph.html b/spec/frontend/fixtures/static/pipeline_graph.html deleted file mode 100644 index d2c30ff9211..00000000000 --- a/spec/frontend/fixtures/static/pipeline_graph.html +++ /dev/null @@ -1,24 +0,0 @@ -<div class="pipeline-visualization js-pipeline-graph"> -<ul class="stage-column-list"> -<li class="stage-column"> -<div class="stage-name"> -<a href="/"> -Test -<div class="builds-container"> -<ul> -<li class="build"> -<div class="curve"></div> -<a> -<svg></svg> -<div> -stop_review -</div> -</a> -</li> -</ul> -</div> -</a> -</div> -</li> -</ul> -</div> diff --git a/spec/frontend/fixtures/timezones.rb b/spec/frontend/fixtures/timezones.rb new file mode 100644 index 00000000000..261dcf5e116 --- /dev/null +++ b/spec/frontend/fixtures/timezones.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe TimeZoneHelper, '(JavaScript fixtures)' do + include JavaScriptFixturesHelpers + include TimeZoneHelper + + let(:response) { @timezones.sort_by! { |tz| tz[:name] }.to_json } + + before(:all) do + clean_frontend_fixtures('timezones/') + end + + it 'timezones/short.json' do + @timezones = timezone_data(format: :short) + end + + it 'timezones/full.json' do + @timezones = timezone_data(format: :full) + end +end diff --git a/spec/frontend/frequent_items/store/actions_spec.js b/spec/frontend/frequent_items/store/actions_spec.js index dacfc7ce707..fb0321545c2 100644 --- a/spec/frontend/frequent_items/store/actions_spec.js +++ b/spec/frontend/frequent_items/store/actions_spec.js @@ -109,7 +109,7 @@ describe('Frequent Items Dropdown Store Actions', () => { }); it('should dispatch `receiveFrequentItemsError`', (done) => { - jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(false); + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false); mockedState.namespace = mockNamespace; mockedState.storageKey = mockStorageKey; diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js index da0ff2a64ec..bc8c6460cf4 100644 --- a/spec/frontend/groups/components/app_spec.js +++ b/spec/frontend/groups/components/app_spec.js @@ -182,7 +182,12 @@ describe('AppComponent', () => { jest.spyOn(window.history, 'replaceState').mockImplementation(() => {}); jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); - const fetchPagePromise = vm.fetchPage(2, null, null, true); + const fetchPagePromise = vm.fetchPage({ + page: 2, + filterGroupsBy: null, + sortBy: null, + archived: true, + }); expect(vm.isLoading).toBe(true); expect(vm.fetchGroups).toHaveBeenCalledWith({ diff --git a/spec/frontend/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js index dc1a10639fc..0ec1ef5a49e 100644 --- a/spec/frontend/groups/components/groups_spec.js +++ b/spec/frontend/groups/components/groups_spec.js @@ -41,13 +41,12 @@ describe('GroupsComponent', () => { vm.change(2); - expect(eventHub.$emit).toHaveBeenCalledWith( - 'fetchPage', - 2, - expect.any(Object), - expect.any(Object), - expect.any(Object), - ); + expect(eventHub.$emit).toHaveBeenCalledWith('fetchPage', { + page: 2, + archived: null, + filterGroupsBy: null, + sortBy: null, + }); }); }); }); diff --git a/spec/frontend/groups/components/invite_members_banner_spec.js b/spec/frontend/groups/components/invite_members_banner_spec.js index 0da2f84f2a1..c81edad499c 100644 --- a/spec/frontend/groups/components/invite_members_banner_spec.js +++ b/spec/frontend/groups/components/invite_members_banner_spec.js @@ -1,29 +1,29 @@ -import { GlBanner, GlButton } from '@gitlab/ui'; +import { GlBanner } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import InviteMembersBanner from '~/groups/components/invite_members_banner.vue'; import eventHub from '~/invite_members/event_hub'; -import { setCookie, parseBoolean } from '~/lib/utils/common_utils'; +import axios from '~/lib/utils/axios_utils'; jest.mock('~/lib/utils/common_utils'); -const isDismissedKey = 'invite_99_1'; const title = 'Collaborate with your team'; const body = "We noticed that you haven't invited anyone to this group. Invite your colleagues so you can discuss issues, collaborate on merge requests, and share your knowledge"; -const svgPath = '/illustrations/background'; -const inviteMembersPath = 'groups/members'; const buttonText = 'Invite your colleagues'; -const trackLabel = 'invite_members_banner'; +const provide = { + svgPath: '/illustrations/background', + inviteMembersPath: 'groups/members', + trackLabel: 'invite_members_banner', + calloutsPath: 'call/out/path', + calloutsFeatureId: 'some-feature-id', + groupId: '1', +}; const createComponent = (stubs = {}) => { return shallowMount(InviteMembersBanner, { - provide: { - svgPath, - inviteMembersPath, - isDismissedKey, - trackLabel, - }, + provide, stubs, }); }; @@ -31,8 +31,10 @@ const createComponent = (stubs = {}) => { describe('InviteMembersBanner', () => { let wrapper; let trackingSpy; + let mockAxios; beforeEach(() => { + mockAxios = new MockAdapter(axios); document.body.dataset.page = 'any:page'; trackingSpy = mockTracking('_category_', undefined, jest.spyOn); }); @@ -40,22 +42,28 @@ describe('InviteMembersBanner', () => { afterEach(() => { wrapper.destroy(); wrapper = null; + mockAxios.restore(); unmockTracking(); }); describe('tracking', () => { + const mockTrackingOnWrapper = () => { + unmockTracking(); + trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); + }; + beforeEach(() => { wrapper = createComponent({ GlBanner }); }); const trackCategory = undefined; - const displayEvent = 'invite_members_banner_displayed'; const buttonClickEvent = 'invite_members_banner_button_clicked'; - const dismissEvent = 'invite_members_banner_dismissed'; it('sends the displayEvent when the banner is displayed', () => { + const displayEvent = 'invite_members_banner_displayed'; + expect(trackingSpy).toHaveBeenCalledWith(trackCategory, displayEvent, { - label: trackLabel, + label: provide.trackLabel, }); }); @@ -74,16 +82,20 @@ describe('InviteMembersBanner', () => { it('sends the buttonClickEvent with correct trackCategory and trackLabel', () => { expect(trackingSpy).toHaveBeenCalledWith(trackCategory, buttonClickEvent, { - label: trackLabel, + label: provide.trackLabel, }); }); }); it('sends the dismissEvent when the banner is dismissed', () => { + mockTrackingOnWrapper(); + mockAxios.onPost(provide.calloutsPath).replyOnce(200); + const dismissEvent = 'invite_members_banner_dismissed'; + wrapper.find(GlBanner).vm.$emit('close'); expect(trackingSpy).toHaveBeenCalledWith(trackCategory, dismissEvent, { - label: trackLabel, + label: provide.trackLabel, }); }); }); @@ -98,7 +110,7 @@ describe('InviteMembersBanner', () => { }); it('uses the svgPath for the banner svgpath', () => { - expect(findBanner().attributes('svgpath')).toBe(svgPath); + expect(findBanner().attributes('svgpath')).toBe(provide.svgPath); }); it('uses the title from options for title', () => { @@ -115,35 +127,20 @@ describe('InviteMembersBanner', () => { }); describe('dismissing', () => { - const findButton = () => wrapper.findAll(GlButton).at(1); - beforeEach(() => { wrapper = createComponent({ GlBanner }); - - findButton().vm.$emit('click'); }); - it('sets iDismissed to true', () => { - expect(wrapper.vm.isDismissed).toBe(true); + it('should render the banner when not dismissed', () => { + expect(wrapper.find(GlBanner).exists()).toBe(true); }); - it('sets the cookie with the isDismissedKey', () => { - expect(setCookie).toHaveBeenCalledWith(isDismissedKey, true); - }); - }); - - describe('when a dismiss cookie exists', () => { - beforeEach(() => { - parseBoolean.mockReturnValue(true); - - wrapper = createComponent({ GlBanner }); - }); - - it('sets isDismissed to true', () => { - expect(wrapper.vm.isDismissed).toBe(true); - }); + it('should close the banner when dismiss is clicked', async () => { + mockAxios.onPost(provide.calloutsPath).replyOnce(200); + expect(wrapper.find(GlBanner).exists()).toBe(true); + wrapper.find(GlBanner).vm.$emit('close'); - it('does not render the banner', () => { + await wrapper.vm.$nextTick(); expect(wrapper.find(GlBanner).exists()).toBe(false); }); }); diff --git a/spec/frontend/groups/components/item_stats_spec.js b/spec/frontend/groups/components/item_stats_spec.js index f350012ebed..49f3f5da43c 100644 --- a/spec/frontend/groups/components/item_stats_spec.js +++ b/spec/frontend/groups/components/item_stats_spec.js @@ -1,4 +1,4 @@ -import { shallowMount } from '@vue/test-utils'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ItemStats from '~/groups/components/item_stats.vue'; import ItemStatsValue from '~/groups/components/item_stats_value.vue'; @@ -12,7 +12,7 @@ describe('ItemStats', () => { }; const createComponent = (props = {}) => { - wrapper = shallowMount(ItemStats, { + wrapper = shallowMountExtended(ItemStats, { propsData: { ...defaultProps, ...props }, }); }; @@ -46,5 +46,31 @@ describe('ItemStats', () => { expect(findItemStatsValue().props('cssClass')).toBe('project-stars'); expect(wrapper.find('.last-updated').exists()).toBe(true); }); + + describe('group specific rendering', () => { + describe.each` + provided | state | data + ${true} | ${'displays'} | ${null} + ${false} | ${'does not display'} | ${{ subgroupCount: undefined, projectCount: undefined }} + `('when provided = $provided', ({ provided, state, data }) => { + beforeEach(() => { + const item = { + ...mockParentGroupItem, + ...data, + type: ITEM_TYPE.GROUP, + }; + + createComponent({ item }); + }); + + it.each` + entity | testId + ${'subgroups'} | ${'subgroups-count'} + ${'projects'} | ${'projects-count'} + `(`${state} $entity count`, ({ testId }) => { + expect(wrapper.findByTestId(testId).exists()).toBe(provided); + }); + }); + }); }); }); diff --git a/spec/frontend/header_search/components/app_spec.js b/spec/frontend/header_search/components/app_spec.js new file mode 100644 index 00000000000..2cbcb73ce5b --- /dev/null +++ b/spec/frontend/header_search/components/app_spec.js @@ -0,0 +1,159 @@ +import { GlSearchBoxByType } from '@gitlab/ui'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import HeaderSearchApp from '~/header_search/components/app.vue'; +import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue'; +import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue'; +import { ENTER_KEY, ESC_KEY } from '~/lib/utils/keys'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { MOCK_SEARCH, MOCK_SEARCH_QUERY, MOCK_USERNAME } from '../mock_data'; + +Vue.use(Vuex); + +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn(), +})); + +describe('HeaderSearchApp', () => { + let wrapper; + + const actionSpies = { + setSearch: jest.fn(), + }; + + const createComponent = (initialState) => { + const store = new Vuex.Store({ + state: { + ...initialState, + }, + actions: actionSpies, + getters: { + searchQuery: () => MOCK_SEARCH_QUERY, + }, + }); + + wrapper = shallowMountExtended(HeaderSearchApp, { + store, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findHeaderSearchInput = () => wrapper.findComponent(GlSearchBoxByType); + const findHeaderSearchDropdown = () => wrapper.findByTestId('header-search-dropdown-menu'); + const findHeaderSearchDefaultItems = () => wrapper.findComponent(HeaderSearchDefaultItems); + const findHeaderSearchScopedItems = () => wrapper.findComponent(HeaderSearchScopedItems); + + describe('template', () => { + it('always renders Header Search Input', () => { + createComponent(); + expect(findHeaderSearchInput().exists()).toBe(true); + }); + + describe.each` + showDropdown | username | showSearchDropdown + ${false} | ${null} | ${false} + ${false} | ${MOCK_USERNAME} | ${false} + ${true} | ${null} | ${false} + ${true} | ${MOCK_USERNAME} | ${true} + `('Header Search Dropdown', ({ showDropdown, username, showSearchDropdown }) => { + describe(`when showDropdown is ${showDropdown} and current_username is ${username}`, () => { + beforeEach(() => { + createComponent(); + window.gon.current_username = username; + wrapper.setData({ showDropdown }); + }); + + it(`should${showSearchDropdown ? '' : ' not'} render`, () => { + expect(findHeaderSearchDropdown().exists()).toBe(showSearchDropdown); + }); + }); + }); + + describe.each` + search | showDefault | showScoped + ${null} | ${true} | ${false} + ${''} | ${true} | ${false} + ${MOCK_SEARCH} | ${false} | ${true} + `('Header Search Dropdown Items', ({ search, showDefault, showScoped }) => { + describe(`when search is ${search}`, () => { + beforeEach(() => { + createComponent({ search }); + window.gon.current_username = MOCK_USERNAME; + wrapper.setData({ showDropdown: true }); + }); + + it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => { + expect(findHeaderSearchDefaultItems().exists()).toBe(showDefault); + }); + + it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => { + expect(findHeaderSearchScopedItems().exists()).toBe(showScoped); + }); + }); + }); + }); + + describe('events', () => { + beforeEach(() => { + createComponent(); + window.gon.current_username = MOCK_USERNAME; + }); + + describe('Header Search Input', () => { + describe('when dropdown is closed', () => { + it('onFocus opens dropdown', async () => { + expect(findHeaderSearchDropdown().exists()).toBe(false); + findHeaderSearchInput().vm.$emit('focus'); + + await wrapper.vm.$nextTick(); + + expect(findHeaderSearchDropdown().exists()).toBe(true); + }); + + it('onClick opens dropdown', async () => { + expect(findHeaderSearchDropdown().exists()).toBe(false); + findHeaderSearchInput().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + expect(findHeaderSearchDropdown().exists()).toBe(true); + }); + }); + + describe('when dropdown is opened', () => { + beforeEach(() => { + wrapper.setData({ showDropdown: true }); + }); + + it('onKey-Escape closes dropdown', async () => { + expect(findHeaderSearchDropdown().exists()).toBe(true); + findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ESC_KEY })); + + await wrapper.vm.$nextTick(); + + expect(findHeaderSearchDropdown().exists()).toBe(false); + }); + }); + + it('calls setSearch when search input event is fired', async () => { + findHeaderSearchInput().vm.$emit('input', MOCK_SEARCH); + + await wrapper.vm.$nextTick(); + + expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), MOCK_SEARCH); + }); + + it('submits a search onKey-Enter', async () => { + findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); + + await wrapper.vm.$nextTick(); + + expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY); + }); + }); + }); +}); diff --git a/spec/frontend/header_search/components/header_search_default_items_spec.js b/spec/frontend/header_search/components/header_search_default_items_spec.js new file mode 100644 index 00000000000..ce083d0df72 --- /dev/null +++ b/spec/frontend/header_search/components/header_search_default_items_spec.js @@ -0,0 +1,81 @@ +import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue'; +import { MOCK_SEARCH_CONTEXT, MOCK_DEFAULT_SEARCH_OPTIONS } from '../mock_data'; + +Vue.use(Vuex); + +describe('HeaderSearchDefaultItems', () => { + let wrapper; + + const createComponent = (initialState) => { + const store = new Vuex.Store({ + state: { + searchContext: MOCK_SEARCH_CONTEXT, + ...initialState, + }, + getters: { + defaultSearchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS, + }, + }); + + wrapper = shallowMount(HeaderSearchDefaultItems, { + store, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findDropdownHeader = () => wrapper.findComponent(GlDropdownSectionHeader); + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => w.text()); + const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); + + describe('template', () => { + describe('Dropdown items', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders item for each option in defaultSearchOptions', () => { + expect(findDropdownItems()).toHaveLength(MOCK_DEFAULT_SEARCH_OPTIONS.length); + }); + + it('renders titles correctly', () => { + const expectedTitles = MOCK_DEFAULT_SEARCH_OPTIONS.map((o) => o.title); + expect(findDropdownItemTitles()).toStrictEqual(expectedTitles); + }); + + it('renders links correctly', () => { + const expectedLinks = MOCK_DEFAULT_SEARCH_OPTIONS.map((o) => o.url); + expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); + }); + }); + + describe.each` + group | project | dropdownTitle + ${null} | ${null} | ${'All GitLab'} + ${{ name: 'Test Group' }} | ${null} | ${'Test Group'} + ${{ name: 'Test Group' }} | ${{ name: 'Test Project' }} | ${'Test Project'} + `('Dropdown Header', ({ group, project, dropdownTitle }) => { + describe(`when group is ${group?.name} and project is ${project?.name}`, () => { + beforeEach(() => { + createComponent({ + searchContext: { + group, + project, + }, + }); + }); + + it(`should render as ${dropdownTitle}`, () => { + expect(findDropdownHeader().text()).toBe(dropdownTitle); + }); + }); + }); + }); +}); diff --git a/spec/frontend/header_search/components/header_search_scoped_items_spec.js b/spec/frontend/header_search/components/header_search_scoped_items_spec.js new file mode 100644 index 00000000000..f0e5e182ec4 --- /dev/null +++ b/spec/frontend/header_search/components/header_search_scoped_items_spec.js @@ -0,0 +1,61 @@ +import { GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { trimText } from 'helpers/text_helper'; +import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue'; +import { MOCK_SEARCH, MOCK_SCOPED_SEARCH_OPTIONS } from '../mock_data'; + +Vue.use(Vuex); + +describe('HeaderSearchScopedItems', () => { + let wrapper; + + const createComponent = (initialState) => { + const store = new Vuex.Store({ + state: { + search: MOCK_SEARCH, + ...initialState, + }, + getters: { + scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS, + }, + }); + + wrapper = shallowMount(HeaderSearchScopedItems, { + store, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => trimText(w.text())); + const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); + + describe('template', () => { + describe('Dropdown items', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders item for each option in scopedSearchOptions', () => { + expect(findDropdownItems()).toHaveLength(MOCK_SCOPED_SEARCH_OPTIONS.length); + }); + + it('renders titles correctly', () => { + const expectedTitles = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => + trimText(`"${MOCK_SEARCH}" ${o.description} ${o.scope || ''}`), + ); + expect(findDropdownItemTitles()).toStrictEqual(expectedTitles); + }); + + it('renders links correctly', () => { + const expectedLinks = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => o.url); + expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); + }); + }); + }); +}); diff --git a/spec/frontend/header_search/mock_data.js b/spec/frontend/header_search/mock_data.js new file mode 100644 index 00000000000..5963ad9c279 --- /dev/null +++ b/spec/frontend/header_search/mock_data.js @@ -0,0 +1,83 @@ +import { + MSG_ISSUES_ASSIGNED_TO_ME, + MSG_ISSUES_IVE_CREATED, + MSG_MR_ASSIGNED_TO_ME, + MSG_MR_IM_REVIEWER, + MSG_MR_IVE_CREATED, + MSG_IN_PROJECT, + MSG_IN_GROUP, + MSG_IN_ALL_GITLAB, +} from '~/header_search/constants'; + +export const MOCK_USERNAME = 'anyone'; + +export const MOCK_SEARCH_PATH = '/search'; + +export const MOCK_ISSUE_PATH = '/dashboard/issues'; + +export const MOCK_MR_PATH = '/dashboard/merge_requests'; + +export const MOCK_ALL_PATH = '/'; + +export const MOCK_PROJECT = { + id: 123, + name: 'MockProject', + path: '/mock-project', +}; + +export const MOCK_GROUP = { + id: 321, + name: 'MockGroup', + path: '/mock-group', +}; + +export const MOCK_SEARCH_QUERY = 'http://gitlab.com/search?search=test'; + +export const MOCK_SEARCH = 'test'; + +export const MOCK_SEARCH_CONTEXT = { + project: null, + project_metadata: {}, + group: null, + group_metadata: {}, +}; + +export const MOCK_DEFAULT_SEARCH_OPTIONS = [ + { + title: MSG_ISSUES_ASSIGNED_TO_ME, + url: `${MOCK_ISSUE_PATH}/?assignee_username=${MOCK_USERNAME}`, + }, + { + title: MSG_ISSUES_IVE_CREATED, + url: `${MOCK_ISSUE_PATH}/?author_username=${MOCK_USERNAME}`, + }, + { + title: MSG_MR_ASSIGNED_TO_ME, + url: `${MOCK_MR_PATH}/?assignee_username=${MOCK_USERNAME}`, + }, + { + title: MSG_MR_IM_REVIEWER, + url: `${MOCK_MR_PATH}/?reviewer_username=${MOCK_USERNAME}`, + }, + { + title: MSG_MR_IVE_CREATED, + url: `${MOCK_MR_PATH}/?author_username=${MOCK_USERNAME}`, + }, +]; + +export const MOCK_SCOPED_SEARCH_OPTIONS = [ + { + scope: MOCK_PROJECT.name, + description: MSG_IN_PROJECT, + url: MOCK_PROJECT.path, + }, + { + scope: MOCK_GROUP.name, + description: MSG_IN_GROUP, + url: MOCK_GROUP.path, + }, + { + description: MSG_IN_ALL_GITLAB, + url: MOCK_ALL_PATH, + }, +]; diff --git a/spec/frontend/header_search/store/actions_spec.js b/spec/frontend/header_search/store/actions_spec.js new file mode 100644 index 00000000000..4530df0d91c --- /dev/null +++ b/spec/frontend/header_search/store/actions_spec.js @@ -0,0 +1,28 @@ +import testAction from 'helpers/vuex_action_helper'; +import * as actions from '~/header_search/store/actions'; +import * as types from '~/header_search/store/mutation_types'; +import createState from '~/header_search/store/state'; +import { MOCK_SEARCH } from '../mock_data'; + +describe('Header Search Store Actions', () => { + let state; + + beforeEach(() => { + state = createState({}); + }); + + afterEach(() => { + state = null; + }); + + describe('setSearch', () => { + it('calls the SET_SEARCH mutation', () => { + return testAction({ + action: actions.setSearch, + payload: MOCK_SEARCH, + state, + expectedMutations: [{ type: types.SET_SEARCH, payload: MOCK_SEARCH }], + }); + }); + }); +}); diff --git a/spec/frontend/header_search/store/getters_spec.js b/spec/frontend/header_search/store/getters_spec.js new file mode 100644 index 00000000000..2ad0a082f6a --- /dev/null +++ b/spec/frontend/header_search/store/getters_spec.js @@ -0,0 +1,211 @@ +import * as getters from '~/header_search/store/getters'; +import initState from '~/header_search/store/state'; +import { + MOCK_USERNAME, + MOCK_SEARCH_PATH, + MOCK_ISSUE_PATH, + MOCK_MR_PATH, + MOCK_SEARCH_CONTEXT, + MOCK_DEFAULT_SEARCH_OPTIONS, + MOCK_SCOPED_SEARCH_OPTIONS, + MOCK_PROJECT, + MOCK_GROUP, + MOCK_ALL_PATH, + MOCK_SEARCH, +} from '../mock_data'; + +describe('Header Search Store Getters', () => { + let state; + + const createState = (initialState) => { + state = initState({ + searchPath: MOCK_SEARCH_PATH, + issuesPath: MOCK_ISSUE_PATH, + mrPath: MOCK_MR_PATH, + searchContext: MOCK_SEARCH_CONTEXT, + ...initialState, + }); + }; + + afterEach(() => { + state = null; + }); + + describe.each` + group | project | expectedPath + ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=undefined&group_id=undefined&scope=issues`} + ${MOCK_GROUP} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=undefined&group_id=${MOCK_GROUP.id}&scope=issues`} + ${MOCK_GROUP} | ${MOCK_PROJECT} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`} + `('searchQuery', ({ group, project, expectedPath }) => { + describe(`when group is ${group?.name} and project is ${project?.name}`, () => { + beforeEach(() => { + createState({ + searchContext: { + group, + project, + scope: 'issues', + }, + }); + state.search = MOCK_SEARCH; + }); + + it(`should return ${expectedPath}`, () => { + expect(getters.searchQuery(state)).toBe(expectedPath); + }); + }); + }); + + describe.each` + group | group_metadata | project | project_metadata | expectedPath + ${null} | ${null} | ${null} | ${null} | ${MOCK_ISSUE_PATH} + ${{ name: 'Test Group' }} | ${{ issues_path: 'group/path' }} | ${null} | ${null} | ${'group/path'} + ${{ name: 'Test Group' }} | ${{ issues_path: 'group/path' }} | ${{ name: 'Test Project' }} | ${{ issues_path: 'project/path' }} | ${'project/path'} + `('scopedIssuesPath', ({ group, group_metadata, project, project_metadata, expectedPath }) => { + describe(`when group is ${group?.name} and project is ${project?.name}`, () => { + beforeEach(() => { + createState({ + searchContext: { + group, + group_metadata, + project, + project_metadata, + }, + }); + }); + + it(`should return ${expectedPath}`, () => { + expect(getters.scopedIssuesPath(state)).toBe(expectedPath); + }); + }); + }); + + describe.each` + group | group_metadata | project | project_metadata | expectedPath + ${null} | ${null} | ${null} | ${null} | ${MOCK_MR_PATH} + ${{ name: 'Test Group' }} | ${{ mr_path: 'group/path' }} | ${null} | ${null} | ${'group/path'} + ${{ name: 'Test Group' }} | ${{ mr_path: 'group/path' }} | ${{ name: 'Test Project' }} | ${{ mr_path: 'project/path' }} | ${'project/path'} + `('scopedMRPath', ({ group, group_metadata, project, project_metadata, expectedPath }) => { + describe(`when group is ${group?.name} and project is ${project?.name}`, () => { + beforeEach(() => { + createState({ + searchContext: { + group, + group_metadata, + project, + project_metadata, + }, + }); + }); + + it(`should return ${expectedPath}`, () => { + expect(getters.scopedMRPath(state)).toBe(expectedPath); + }); + }); + }); + + describe.each` + group | project | expectedPath + ${null} | ${null} | ${null} + ${MOCK_GROUP} | ${null} | ${null} + ${MOCK_GROUP} | ${MOCK_PROJECT} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`} + `('projectUrl', ({ group, project, expectedPath }) => { + describe(`when group is ${group?.name} and project is ${project?.name}`, () => { + beforeEach(() => { + createState({ + searchContext: { + group, + project, + scope: 'issues', + }, + }); + state.search = MOCK_SEARCH; + }); + + it(`should return ${expectedPath}`, () => { + expect(getters.projectUrl(state)).toBe(expectedPath); + }); + }); + }); + + describe.each` + group | project | expectedPath + ${null} | ${null} | ${null} + ${MOCK_GROUP} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues`} + ${MOCK_GROUP} | ${MOCK_PROJECT} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues`} + `('groupUrl', ({ group, project, expectedPath }) => { + describe(`when group is ${group?.name} and project is ${project?.name}`, () => { + beforeEach(() => { + createState({ + searchContext: { + group, + project, + scope: 'issues', + }, + }); + state.search = MOCK_SEARCH; + }); + + it(`should return ${expectedPath}`, () => { + expect(getters.groupUrl(state)).toBe(expectedPath); + }); + }); + }); + + describe('allUrl', () => { + const expectedPath = `${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues`; + + beforeEach(() => { + createState({ + searchContext: { + scope: 'issues', + }, + }); + state.search = MOCK_SEARCH; + }); + + it(`should return ${expectedPath}`, () => { + expect(getters.allUrl(state)).toBe(expectedPath); + }); + }); + + describe('defaultSearchOptions', () => { + const mockGetters = { + scopedIssuesPath: MOCK_ISSUE_PATH, + scopedMRPath: MOCK_MR_PATH, + }; + + beforeEach(() => { + createState(); + window.gon.current_username = MOCK_USERNAME; + }); + + it('returns the correct array', () => { + expect(getters.defaultSearchOptions(state, mockGetters)).toStrictEqual( + MOCK_DEFAULT_SEARCH_OPTIONS, + ); + }); + }); + + describe('scopedSearchOptions', () => { + const mockGetters = { + projectUrl: MOCK_PROJECT.path, + groupUrl: MOCK_GROUP.path, + allUrl: MOCK_ALL_PATH, + }; + + beforeEach(() => { + createState({ + searchContext: { + project: MOCK_PROJECT, + group: MOCK_GROUP, + }, + }); + }); + + it('returns the correct array', () => { + expect(getters.scopedSearchOptions(state, mockGetters)).toStrictEqual( + MOCK_SCOPED_SEARCH_OPTIONS, + ); + }); + }); +}); diff --git a/spec/frontend/header_search/store/mutations_spec.js b/spec/frontend/header_search/store/mutations_spec.js new file mode 100644 index 00000000000..8196c06099d --- /dev/null +++ b/spec/frontend/header_search/store/mutations_spec.js @@ -0,0 +1,20 @@ +import * as types from '~/header_search/store/mutation_types'; +import mutations from '~/header_search/store/mutations'; +import createState from '~/header_search/store/state'; +import { MOCK_SEARCH } from '../mock_data'; + +describe('Header Search Store Mutations', () => { + let state; + + beforeEach(() => { + state = createState({}); + }); + + describe('SET_SEARCH', () => { + it('sets search to value', () => { + mutations[types.SET_SEARCH](state, MOCK_SEARCH); + + expect(state.search).toBe(MOCK_SEARCH); + }); + }); +}); diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js index 4ca6d7259bd..0d43accb7e5 100644 --- a/spec/frontend/header_spec.js +++ b/spec/frontend/header_spec.js @@ -59,8 +59,8 @@ describe('Header', () => { beforeEach(() => { setFixtures(` <li class="js-nav-user-dropdown"> - <a class="js-buy-pipeline-minutes-link" data-track-event="click_buy_ci_minutes" data-track-label="free" data-track-property="user_dropdown">Buy Pipeline minutes</a> - <a class="js-upgrade-plan-link" data-track-event="click_upgrade_link" data-track-label="free" data-track-property="user_dropdown">Upgrade</a> + <a class="js-buy-pipeline-minutes-link" data-track-action="click_buy_ci_minutes" data-track-label="free" data-track-property="user_dropdown">Buy Pipeline minutes</a> + <a class="js-upgrade-plan-link" data-track-action="click_upgrade_link" data-track-label="free" data-track-property="user_dropdown">Upgrade</a> </li>`); trackingSpy = mockTracking('_category_', $('.js-nav-user-dropdown').element, jest.spyOn); diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index 47bcfb59a5f..c2212eea849 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -8,6 +8,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import waitUsingRealTimer from 'helpers/wait_using_real_timer'; import { exampleConfigs, exampleFiles } from 'jest/ide/lib/editorconfig/mock_data'; import { EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN } from '~/editor/constants'; +import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext'; import SourceEditor from '~/editor/source_editor'; import RepoEditor from '~/ide/components/repo_editor.vue'; @@ -25,6 +26,7 @@ import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer import { file } from '../helpers'; const PREVIEW_MARKDOWN_PATH = '/foo/bar/preview_markdown'; +const CURRENT_PROJECT_ID = 'gitlab-org/gitlab'; const defaultFileProps = { ...file('file.txt'), @@ -63,7 +65,7 @@ const prepareStore = (state, activeFile) => { const localState = { openFiles: [activeFile], projects: { - 'gitlab-org/gitlab': { + [CURRENT_PROJECT_ID]: { branches: { main: { name: 'main', @@ -74,7 +76,7 @@ const prepareStore = (state, activeFile) => { }, }, }, - currentProjectId: 'gitlab-org/gitlab', + currentProjectId: CURRENT_PROJECT_ID, currentBranchId: 'main', entries: { [activeFile.path]: activeFile, @@ -98,6 +100,7 @@ describe('RepoEditor', () => { let createInstanceSpy; let createDiffInstanceSpy; let createModelSpy; + let applyExtensionSpy; const waitForEditorSetup = () => new Promise((resolve) => { @@ -124,11 +127,28 @@ describe('RepoEditor', () => { const findEditor = () => wrapper.find('[data-testid="editor-container"]'); const findTabs = () => wrapper.findAll('.ide-mode-tabs .nav-links li'); const findPreviewTab = () => wrapper.find('[data-testid="preview-tab"]'); + const expectEditorMarkdownExtension = (shouldHaveExtension) => { + if (shouldHaveExtension) { + expect(applyExtensionSpy).toHaveBeenCalledWith( + wrapper.vm.editor, + expect.any(EditorMarkdownExtension), + ); + // TODO: spying on extensions causes Jest to blow up, so we have to assert on + // the public property the extension adds, as opposed to the args passed to the ctor + expect(wrapper.vm.editor.previewMarkdownPath).toBe(PREVIEW_MARKDOWN_PATH); + } else { + expect(applyExtensionSpy).not.toHaveBeenCalledWith( + wrapper.vm.editor, + expect.any(EditorMarkdownExtension), + ); + } + }; beforeEach(() => { createInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_CODE_INSTANCE_FN); createDiffInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_DIFF_INSTANCE_FN); createModelSpy = jest.spyOn(monacoEditor, 'createModel'); + applyExtensionSpy = jest.spyOn(SourceEditor, 'instanceApplyExtension'); jest.spyOn(service, 'getFileData').mockResolvedValue(); jest.spyOn(service, 'getRawFileData').mockResolvedValue(); }); @@ -280,13 +300,8 @@ describe('RepoEditor', () => { '$prefix install markdown extension for $activeFile.name in $viewer viewer', async ({ activeFile, viewer, shouldHaveMarkdownExtension } = {}) => { await createComponent({ state: { viewer }, activeFile }); - if (shouldHaveMarkdownExtension) { - expect(vm.editor.previewMarkdownPath).toBe(PREVIEW_MARKDOWN_PATH); - expect(vm.editor.togglePreview).toBeDefined(); - } else { - expect(vm.editor.previewMarkdownPath).toBeUndefined(); - expect(vm.editor.togglePreview).toBeUndefined(); - } + + expectEditorMarkdownExtension(shouldHaveMarkdownExtension); }, ); }); diff --git a/spec/frontend/ide/services/terminals_spec.js b/spec/frontend/ide/services/terminals_spec.js new file mode 100644 index 00000000000..788fdb6471c --- /dev/null +++ b/spec/frontend/ide/services/terminals_spec.js @@ -0,0 +1,51 @@ +import MockAdapter from 'axios-mock-adapter'; +import * as terminalService from '~/ide/services/terminals'; +import axios from '~/lib/utils/axios_utils'; + +const TEST_PROJECT_PATH = 'lorem/ipsum/dolar'; +const TEST_BRANCH = 'ref'; + +describe('~/ide/services/terminals', () => { + let axiosSpy; + let mock; + const prevRelativeUrlRoot = gon.relative_url_root; + + beforeEach(() => { + axiosSpy = jest.fn().mockReturnValue([200, {}]); + + mock = new MockAdapter(axios); + mock.onPost(/.*/).reply((...args) => axiosSpy(...args)); + }); + + afterEach(() => { + gon.relative_url_root = prevRelativeUrlRoot; + mock.restore(); + }); + + it.each` + method | relativeUrlRoot | url + ${'checkConfig'} | ${''} | ${`/${TEST_PROJECT_PATH}/ide_terminals/check_config`} + ${'checkConfig'} | ${'/'} | ${`/${TEST_PROJECT_PATH}/ide_terminals/check_config`} + ${'checkConfig'} | ${'/gitlabbin'} | ${`/gitlabbin/${TEST_PROJECT_PATH}/ide_terminals/check_config`} + ${'create'} | ${''} | ${`/${TEST_PROJECT_PATH}/ide_terminals`} + ${'create'} | ${'/'} | ${`/${TEST_PROJECT_PATH}/ide_terminals`} + ${'create'} | ${'/gitlabbin'} | ${`/gitlabbin/${TEST_PROJECT_PATH}/ide_terminals`} + `( + 'when $method called, posts request to $url (relative_url_root=$relativeUrlRoot)', + async ({ method, url, relativeUrlRoot }) => { + gon.relative_url_root = relativeUrlRoot; + + await terminalService[method](TEST_PROJECT_PATH, TEST_BRANCH); + + expect(axiosSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: JSON.stringify({ + branch: TEST_BRANCH, + format: 'json', + }), + url, + }), + ); + }, + ); +}); diff --git a/spec/frontend/ide/utils_spec.js b/spec/frontend/ide/utils_spec.js index 00733615f81..2f8447af518 100644 --- a/spec/frontend/ide/utils_spec.js +++ b/spec/frontend/ide/utils_spec.js @@ -86,6 +86,14 @@ describe('WebIDE utils', () => { expect(isTextFile({ name: 'abc.dat', content: '' })).toBe(true); expect(isTextFile({ name: 'abc.dat', content: ' ' })).toBe(true); }); + + it('returns true if there is a `binary` property already set on the file object', () => { + expect(isTextFile({ name: 'abc.txt', content: '' })).toBe(true); + expect(isTextFile({ name: 'abc.txt', content: '', binary: true })).toBe(false); + + expect(isTextFile({ name: 'abc.tex', content: 'éêė' })).toBe(false); + expect(isTextFile({ name: 'abc.tex', content: 'éêė', binary: false })).toBe(true); + }); }); describe('trimPathComponents', () => { diff --git a/spec/frontend/import_entities/import_groups/components/import_actions_cell_spec.js b/spec/frontend/import_entities/import_groups/components/import_actions_cell_spec.js new file mode 100644 index 00000000000..60f0780fdb3 --- /dev/null +++ b/spec/frontend/import_entities/import_groups/components/import_actions_cell_spec.js @@ -0,0 +1,90 @@ +import { GlButton, GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { STATUSES } from '~/import_entities/constants'; +import ImportActionsCell from '~/import_entities/import_groups/components/import_actions_cell.vue'; +import { generateFakeEntry } from '../graphql/fixtures'; + +describe('import actions cell', () => { + let wrapper; + + const createComponent = (props) => { + wrapper = shallowMount(ImportActionsCell, { + propsData: { + groupPathRegex: /^[a-zA-Z]+$/, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when import status is NONE', () => { + beforeEach(() => { + const group = generateFakeEntry({ id: 1, status: STATUSES.NONE }); + createComponent({ group }); + }); + + it('renders import button', () => { + const button = wrapper.findComponent(GlButton); + expect(button.exists()).toBe(true); + expect(button.text()).toBe('Import'); + }); + + it('does not render icon with a hint', () => { + expect(wrapper.findComponent(GlIcon).exists()).toBe(false); + }); + }); + + describe('when import status is FINISHED', () => { + beforeEach(() => { + const group = generateFakeEntry({ id: 1, status: STATUSES.FINISHED }); + createComponent({ group }); + }); + + it('renders re-import button', () => { + const button = wrapper.findComponent(GlButton); + expect(button.exists()).toBe(true); + expect(button.text()).toBe('Re-import'); + }); + + it('renders icon with a hint', () => { + const icon = wrapper.findComponent(GlIcon); + expect(icon.exists()).toBe(true); + expect(icon.attributes().title).toBe( + 'Re-import creates a new group. It does not sync with the existing group.', + ); + }); + }); + + it('does not render import button when group import is in progress', () => { + const group = generateFakeEntry({ id: 1, status: STATUSES.STARTED }); + createComponent({ group }); + + const button = wrapper.findComponent(GlButton); + expect(button.exists()).toBe(false); + }); + + it('renders import button as disabled when there are validation errors', () => { + const group = generateFakeEntry({ + id: 1, + status: STATUSES.NONE, + validation_errors: [{ field: 'new_name', message: 'something ' }], + }); + createComponent({ group }); + + const button = wrapper.findComponent(GlButton); + expect(button.props().disabled).toBe(true); + }); + + it('emits import-group event when import button is clicked', () => { + const group = generateFakeEntry({ id: 1, status: STATUSES.NONE }); + createComponent({ group }); + + const button = wrapper.findComponent(GlButton); + button.vm.$emit('click'); + + expect(wrapper.emitted('import-group')).toHaveLength(1); + }); +}); diff --git a/spec/frontend/import_entities/import_groups/components/import_source_cell_spec.js b/spec/frontend/import_entities/import_groups/components/import_source_cell_spec.js new file mode 100644 index 00000000000..2a56efd1cbb --- /dev/null +++ b/spec/frontend/import_entities/import_groups/components/import_source_cell_spec.js @@ -0,0 +1,59 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { STATUSES } from '~/import_entities/constants'; +import ImportSourceCell from '~/import_entities/import_groups/components/import_source_cell.vue'; +import { generateFakeEntry } from '../graphql/fixtures'; + +describe('import source cell', () => { + let wrapper; + let group; + + const createComponent = (props) => { + wrapper = shallowMount(ImportSourceCell, { + propsData: { + ...props, + }, + stubs: { GlSprintf }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when group status is NONE', () => { + beforeEach(() => { + group = generateFakeEntry({ id: 1, status: STATUSES.NONE }); + createComponent({ group }); + }); + + it('renders link to a group', () => { + const link = wrapper.findComponent(GlLink); + expect(link.attributes().href).toBe(group.web_url); + expect(link.text()).toContain(group.full_path); + }); + + it('does not render last imported line', () => { + expect(wrapper.text()).not.toContain('Last imported to'); + }); + }); + + describe('when group status is FINISHED', () => { + beforeEach(() => { + group = generateFakeEntry({ id: 1, status: STATUSES.FINISHED }); + createComponent({ group }); + }); + + it('renders link to a group', () => { + const link = wrapper.findComponent(GlLink); + expect(link.attributes().href).toBe(group.web_url); + expect(link.text()).toContain(group.full_path); + }); + + it('renders last imported line', () => { + expect(wrapper.text()).toMatchInterpolatedText( + 'fake_group_1 Last imported to root/last-group1', + ); + }); + }); +}); diff --git a/spec/frontend/import_entities/import_groups/components/import_table_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_spec.js index bbd8463e685..f43e545e049 100644 --- a/spec/frontend/import_entities/import_groups/components/import_table_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_table_spec.js @@ -15,6 +15,7 @@ import stubChildren from 'helpers/stub_children'; import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; import { STATUSES } from '~/import_entities/constants'; +import ImportActionsCell from '~/import_entities/import_groups/components/import_actions_cell.vue'; import ImportTable from '~/import_entities/import_groups/components/import_table.vue'; import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue'; import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql'; @@ -163,11 +164,8 @@ describe('import table', () => { it('invokes importGroups mutation when row button is clicked', async () => { jest.spyOn(apolloProvider.defaultClient, 'mutate'); - const triggerImportButton = wrapper - .findAllComponents(GlButton) - .wrappers.find((w) => w.text() === 'Import'); - triggerImportButton.vm.$emit('click'); + wrapper.findComponent(ImportActionsCell).vm.$emit('import-group'); await waitForPromises(); expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({ mutation: importGroupsMutation, @@ -329,7 +327,7 @@ describe('import table', () => { }); it('does not allow selecting already started groups', async () => { - const NEW_GROUPS = [generateFakeEntry({ id: 1, status: STATUSES.FINISHED })]; + const NEW_GROUPS = [generateFakeEntry({ id: 1, status: STATUSES.STARTED })]; createComponent({ bulkImportSourceGroups: () => ({ diff --git a/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js b/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js index 8231297e594..be83a61841f 100644 --- a/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js @@ -1,14 +1,10 @@ -import { GlButton, GlDropdownItem, GlLink, GlFormInput } from '@gitlab/ui'; +import { GlDropdownItem, GlFormInput } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; import ImportGroupDropdown from '~/import_entities/components/group_dropdown.vue'; import { STATUSES } from '~/import_entities/constants'; import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue'; import { availableNamespacesFixture } from '../graphql/fixtures'; -Vue.use(VueApollo); - const getFakeGroup = (status) => ({ web_url: 'https://fake.host/', full_path: 'fake_group_1', @@ -26,9 +22,6 @@ describe('import target cell', () => { let wrapper; let group; - const findByText = (cmp, text) => { - return wrapper.findAll(cmp).wrappers.find((node) => node.text().indexOf(text) === 0); - }; const findNameInput = () => wrapper.find(GlFormInput); const findNamespaceDropdown = () => wrapper.find(ImportGroupDropdown); @@ -117,10 +110,6 @@ describe('import target cell', () => { createComponent({ group }); }); - it('does not render Import button', () => { - expect(findByText(GlButton, 'Import')).toBe(undefined); - }); - it('renders namespace dropdown as disabled', () => { expect(findNamespaceDropdown().attributes('disabled')).toBe('true'); }); @@ -132,17 +121,8 @@ describe('import target cell', () => { createComponent({ group }); }); - it('does not render Import button', () => { - expect(findByText(GlButton, 'Import')).toBe(undefined); - }); - - it('does not render namespace dropdown', () => { - expect(findNamespaceDropdown().exists()).toBe(false); - }); - - it('renders target as link', () => { - const TARGET_LINK = `${group.import_target.target_namespace}/${group.import_target.new_name}`; - expect(findByText(GlLink, TARGET_LINK).exists()).toBe(true); + it('renders namespace dropdown as enabled', () => { + expect(findNamespaceDropdown().attributes('disabled')).toBe(undefined); }); }); @@ -179,9 +159,6 @@ describe('import target cell', () => { }, }); - jest.runOnlyPendingTimers(); - await nextTick(); - expect(wrapper.text()).toContain(FAKE_ERROR_MESSAGE); }); }); diff --git a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js index ec50dfd037f..e1d65095888 100644 --- a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js +++ b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js @@ -259,6 +259,10 @@ describe('Bulk import resolvers', () => { target_namespace: 'root', new_name: 'group1', }, + last_import_target: { + target_namespace: 'root', + new_name: 'group1', + }, validation_errors: [], }, ], @@ -414,19 +418,32 @@ describe('Bulk import resolvers', () => { }); }); - it('setImportProgress updates group progress', async () => { + it('setImportProgress updates group progress and sets import target', async () => { const NEW_STATUS = 'dummy'; const FAKE_JOB_ID = 5; + const IMPORT_TARGET = { + __typename: 'ClientBulkImportTarget', + new_name: 'fake_name', + target_namespace: 'fake_target', + }; const { data: { - setImportProgress: { progress }, + setImportProgress: { progress, last_import_target: lastImportTarget }, }, } = await client.mutate({ mutation: setImportProgressMutation, - variables: { sourceGroupId: GROUP_ID, status: NEW_STATUS, jobId: FAKE_JOB_ID }, + variables: { + sourceGroupId: GROUP_ID, + status: NEW_STATUS, + jobId: FAKE_JOB_ID, + importTarget: IMPORT_TARGET, + }, }); - expect(progress).toMatchObject({ + expect(lastImportTarget).toStrictEqual(IMPORT_TARGET); + + expect(progress).toStrictEqual({ + __typename: clientTypenames.BulkImportProgress, id: FAKE_JOB_ID, status: NEW_STATUS, }); @@ -442,7 +459,8 @@ describe('Bulk import resolvers', () => { variables: { id: FAKE_JOB_ID, status: NEW_STATUS }, }); - expect(statusInResponse).toMatchObject({ + expect(statusInResponse).toStrictEqual({ + __typename: clientTypenames.BulkImportProgress, id: FAKE_JOB_ID, status: NEW_STATUS, }); @@ -460,7 +478,13 @@ describe('Bulk import resolvers', () => { variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD, message: FAKE_MESSAGE }, }); - expect(validationErrors).toMatchObject([{ field: FAKE_FIELD, message: FAKE_MESSAGE }]); + expect(validationErrors).toStrictEqual([ + { + __typename: clientTypenames.BulkImportValidationError, + field: FAKE_FIELD, + message: FAKE_MESSAGE, + }, + ]); }); it('removeValidationError removes error from group', async () => { @@ -481,7 +505,7 @@ describe('Bulk import resolvers', () => { variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD }, }); - expect(validationErrors).toMatchObject([]); + expect(validationErrors).toStrictEqual([]); }); }); }); diff --git a/spec/frontend/import_entities/import_groups/graphql/fixtures.js b/spec/frontend/import_entities/import_groups/graphql/fixtures.js index 6f66066b312..d1bd52693b6 100644 --- a/spec/frontend/import_entities/import_groups/graphql/fixtures.js +++ b/spec/frontend/import_entities/import_groups/graphql/fixtures.js @@ -9,6 +9,10 @@ export const generateFakeEntry = ({ id, status, ...rest }) => ({ target_namespace: 'root', new_name: `group${id}`, }, + last_import_target: { + target_namespace: 'root', + new_name: `last-group${id}`, + }, id, progress: { id: `test-${id}`, diff --git a/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js b/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js index bae715edac0..f06babcb149 100644 --- a/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js +++ b/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js @@ -20,7 +20,7 @@ describe('SourceGroupsManager', () => { describe('storage management', () => { const IMPORT_ID = 1; - const IMPORT_TARGET = { destination_name: 'demo', destination_namespace: 'foo' }; + const IMPORT_TARGET = { new_name: 'demo', target_namespace: 'foo' }; const STATUS = 'FAKE_STATUS'; const FAKE_GROUP = { id: 1, import_target: IMPORT_TARGET, status: STATUS }; diff --git a/spec/frontend/import_entities/import_projects/store/actions_spec.js b/spec/frontend/import_entities/import_projects/store/actions_spec.js index f2bfc61381c..0ebe8525b5a 100644 --- a/spec/frontend/import_entities/import_projects/store/actions_spec.js +++ b/spec/frontend/import_entities/import_projects/store/actions_spec.js @@ -85,7 +85,7 @@ describe('import_projects store actions', () => { afterEach(() => mock.restore()); - it('commits SET_PAGE, REQUEST_REPOS, RECEIVE_REPOS_SUCCESS mutations on a successful request', () => { + it('commits REQUEST_REPOS, SET_PAGE, RECEIVE_REPOS_SUCCESS mutations on a successful request', () => { mock.onGet(MOCK_ENDPOINT).reply(200, payload); return testAction( @@ -93,8 +93,8 @@ describe('import_projects store actions', () => { null, localState, [ - { type: SET_PAGE, payload: 1 }, { type: REQUEST_REPOS }, + { type: SET_PAGE, payload: 1 }, { type: RECEIVE_REPOS_SUCCESS, payload: convertObjectPropsToCamelCase(payload, { deep: true }), @@ -104,19 +104,14 @@ describe('import_projects store actions', () => { ); }); - it('commits SET_PAGE, REQUEST_REPOS, RECEIVE_REPOS_ERROR and SET_PAGE again mutations on an unsuccessful request', () => { + it('commits REQUEST_REPOS, RECEIVE_REPOS_ERROR mutations on an unsuccessful request', () => { mock.onGet(MOCK_ENDPOINT).reply(500); return testAction( fetchRepos, null, localState, - [ - { type: SET_PAGE, payload: 1 }, - { type: REQUEST_REPOS }, - { type: SET_PAGE, payload: 0 }, - { type: RECEIVE_REPOS_ERROR }, - ], + [{ type: REQUEST_REPOS }, { type: RECEIVE_REPOS_ERROR }], [], ); }); @@ -135,7 +130,7 @@ describe('import_projects store actions', () => { expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?page=${localStateWithPage.pageInfo.page + 1}`); }); - it('correctly updates current page on an unsuccessful request', () => { + it('correctly keeps current page on an unsuccessful request', () => { mock.onGet(MOCK_ENDPOINT).reply(500); const CURRENT_PAGE = 5; @@ -143,10 +138,7 @@ describe('import_projects store actions', () => { fetchRepos, null, { ...localState, pageInfo: { page: CURRENT_PAGE } }, - expect.arrayContaining([ - { type: SET_PAGE, payload: CURRENT_PAGE + 1 }, - { type: SET_PAGE, payload: CURRENT_PAGE }, - ]), + expect.arrayContaining([]), [], ); }); @@ -159,12 +151,7 @@ describe('import_projects store actions', () => { fetchRepos, null, { ...localState, filter: 'filter' }, - [ - { type: SET_PAGE, payload: 1 }, - { type: REQUEST_REPOS }, - { type: SET_PAGE, payload: 0 }, - { type: RECEIVE_REPOS_ERROR }, - ], + [{ type: REQUEST_REPOS }, { type: RECEIVE_REPOS_ERROR }], [], ); @@ -183,8 +170,8 @@ describe('import_projects store actions', () => { null, { ...localState, filter: 'filter' }, [ - { type: SET_PAGE, payload: 1 }, { type: REQUEST_REPOS }, + { type: SET_PAGE, payload: 1 }, { type: RECEIVE_REPOS_SUCCESS, payload: convertObjectPropsToCamelCase(payload, { deep: true }), diff --git a/spec/frontend/invite_members/components/import_a_project_modal_spec.js b/spec/frontend/invite_members/components/import_a_project_modal_spec.js new file mode 100644 index 00000000000..fecbf84fb57 --- /dev/null +++ b/spec/frontend/invite_members/components/import_a_project_modal_spec.js @@ -0,0 +1,167 @@ +import { GlFormGroup, GlSprintf, GlModal } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import { stubComponent } from 'helpers/stub_component'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import * as ProjectsApi from '~/api/projects_api'; +import ImportAProjectModal from '~/invite_members/components/import_a_project_modal.vue'; +import ProjectSelect from '~/invite_members/components/project_select.vue'; +import axios from '~/lib/utils/axios_utils'; + +let wrapper; +let mock; + +const projectId = '1'; +const projectName = 'test name'; +const projectToBeImported = { id: '2' }; +const $toast = { + show: jest.fn(), +}; + +const createComponent = () => { + wrapper = shallowMountExtended(ImportAProjectModal, { + propsData: { + projectId, + projectName, + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: + '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>', + }), + GlSprintf, + GlFormGroup: stubComponent(GlFormGroup, { + props: ['state', 'invalidFeedback'], + }), + }, + mocks: { + $toast, + }, + }); +}; + +beforeEach(() => { + gon.api_version = 'v4'; + mock = new MockAdapter(axios); +}); + +afterEach(() => { + wrapper.destroy(); + mock.restore(); +}); + +describe('ImportAProjectModal', () => { + const findIntroText = () => wrapper.find({ ref: 'modalIntro' }).text(); + const findCancelButton = () => wrapper.findByTestId('cancel-button'); + const findImportButton = () => wrapper.findByTestId('import-button'); + const clickImportButton = () => findImportButton().vm.$emit('click'); + const clickCancelButton = () => findCancelButton().vm.$emit('click'); + const findFormGroup = () => wrapper.findByTestId('form-group'); + const formGroupInvalidFeedback = () => findFormGroup().props('invalidFeedback'); + const formGroupErrorState = () => findFormGroup().props('state'); + const findProjectSelect = () => wrapper.findComponent(ProjectSelect); + + describe('rendering the modal', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the modal with the correct title', () => { + expect(wrapper.findComponent(GlModal).props('title')).toBe( + 'Import members from another project', + ); + }); + + it('renders the Cancel button text correctly', () => { + expect(findCancelButton().text()).toBe('Cancel'); + }); + + it('renders the Import button text correctly', () => { + expect(findImportButton().text()).toBe('Import project members'); + }); + + it('renders the modal intro text correctly', () => { + expect(findIntroText()).toBe("You're importing members to the test name project."); + }); + + it('renders the Import button modal without isLoading', () => { + expect(findImportButton().props('loading')).toBe(false); + }); + + it('sets isLoading to true when the Invite button is clicked', async () => { + clickImportButton(); + + await wrapper.vm.$nextTick(); + + expect(findImportButton().props('loading')).toBe(true); + }); + }); + + describe('submitting the import form', () => { + describe('when the import is successful', () => { + beforeEach(() => { + createComponent(); + + findProjectSelect().vm.$emit('input', projectToBeImported); + + jest.spyOn(ProjectsApi, 'importProjectMembers').mockResolvedValue(); + + clickImportButton(); + }); + + it('calls Api importProjectMembers', () => { + expect(ProjectsApi.importProjectMembers).toHaveBeenCalledWith( + projectId, + projectToBeImported.id, + ); + }); + + it('displays the successful toastMessage', () => { + expect($toast.show).toHaveBeenCalledWith( + 'Successfully imported', + wrapper.vm.$options.toastOptions, + ); + }); + + it('sets isLoading to false after success', () => { + expect(findImportButton().props('loading')).toBe(false); + }); + }); + + describe('when the import fails', () => { + beforeEach(async () => { + createComponent(); + + findProjectSelect().vm.$emit('input', projectToBeImported); + + jest + .spyOn(ProjectsApi, 'importProjectMembers') + .mockRejectedValue({ response: { data: { success: false } } }); + + clickImportButton(); + await waitForPromises(); + }); + + it('displays the generic error message', () => { + expect(formGroupInvalidFeedback()).toBe('Unable to import project members'); + expect(formGroupErrorState()).toBe(false); + }); + + it('sets isLoading to false after error', () => { + expect(findImportButton().props('loading')).toBe(false); + }); + + it('clears the error when the modal is closed with an error', async () => { + expect(formGroupInvalidFeedback()).toBe('Unable to import project members'); + expect(formGroupErrorState()).toBe(false); + + clickCancelButton(); + + await wrapper.vm.$nextTick(); + + expect(formGroupInvalidFeedback()).toBe(''); + expect(formGroupErrorState()).not.toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/invite_members/components/invite_members_trigger_spec.js b/spec/frontend/invite_members/components/invite_members_trigger_spec.js index f57af61ad5b..b2ebb9e4a47 100644 --- a/spec/frontend/invite_members/components/invite_members_trigger_spec.js +++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js @@ -79,14 +79,14 @@ describe.each(['button', 'anchor'])('with triggerElement as %s', (triggerElement it('does not add tracking attributes', () => { createComponent(); - expect(findButton().attributes('data-track-event')).toBeUndefined(); + expect(findButton().attributes('data-track-action')).toBeUndefined(); expect(findButton().attributes('data-track-label')).toBeUndefined(); }); it('adds tracking attributes', () => { createComponent({ label: '_label_', event: '_event_' }); - expect(findButton().attributes('data-track-event')).toBe('_event_'); + expect(findButton().attributes('data-track-action')).toBe('_event_'); expect(findButton().attributes('data-track-label')).toBe('_label_'); }); }); diff --git a/spec/frontend/invite_members/components/project_select_spec.js b/spec/frontend/invite_members/components/project_select_spec.js new file mode 100644 index 00000000000..acc062b5fff --- /dev/null +++ b/spec/frontend/invite_members/components/project_select_spec.js @@ -0,0 +1,105 @@ +import { GlSearchBoxByType, GlAvatarLabeled, GlDropdownItem } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import * as projectsApi from '~/api/projects_api'; +import ProjectSelect from '~/invite_members/components/project_select.vue'; +import { allProjects, project1 } from '../mock_data/api_response_data'; + +describe('ProjectSelect', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(ProjectSelect, {}); + }; + + beforeEach(() => { + jest.spyOn(projectsApi, 'getProjects').mockResolvedValue(allProjects); + + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType); + const findDropdownItem = (index) => wrapper.findAllComponents(GlDropdownItem).at(index); + const findAvatarLabeled = (index) => findDropdownItem(index).findComponent(GlAvatarLabeled); + const findEmptyResultMessage = () => wrapper.findByTestId('empty-result-message'); + const findErrorMessage = () => wrapper.findByTestId('error-message'); + + it('renders GlSearchBoxByType with default attributes', () => { + expect(findSearchBoxByType().exists()).toBe(true); + expect(findSearchBoxByType().vm.$attrs).toMatchObject({ + placeholder: 'Search projects', + }); + }); + + describe('when user types in the search input', () => { + let resolveApiRequest; + let rejectApiRequest; + + beforeEach(() => { + jest.spyOn(projectsApi, 'getProjects').mockImplementation( + () => + new Promise((resolve, reject) => { + resolveApiRequest = resolve; + rejectApiRequest = reject; + }), + ); + + findSearchBoxByType().vm.$emit('input', project1.name); + }); + + it('calls the API', () => { + resolveApiRequest({ data: allProjects }); + + expect(projectsApi.getProjects).toHaveBeenCalledWith(project1.name, { + active: true, + exclude_internal: true, + }); + }); + + it('displays loading icon while waiting for API call to resolve and then sets loading false', async () => { + expect(findSearchBoxByType().props('isLoading')).toBe(true); + + resolveApiRequest({ data: allProjects }); + await waitForPromises(); + + expect(findSearchBoxByType().props('isLoading')).toBe(false); + expect(findEmptyResultMessage().exists()).toBe(false); + expect(findErrorMessage().exists()).toBe(false); + }); + + it('displays a dropdown item and avatar for each project fetched', async () => { + resolveApiRequest({ data: allProjects }); + await waitForPromises(); + + allProjects.forEach((project, index) => { + expect(findDropdownItem(index).attributes('name')).toBe(project.name_with_namespace); + expect(findAvatarLabeled(index).attributes()).toMatchObject({ + src: project.avatar_url, + 'entity-id': String(project.id), + 'entity-name': project.name_with_namespace, + }); + expect(findAvatarLabeled(index).props('label')).toBe(project.name_with_namespace); + }); + }); + + it('displays the empty message when the API results are empty', async () => { + resolveApiRequest({ data: [] }); + await waitForPromises(); + + expect(findEmptyResultMessage().text()).toBe('No matching results'); + }); + + it('displays the error message when the fetch fails', async () => { + rejectApiRequest(); + await waitForPromises(); + + expect(findErrorMessage().text()).toBe( + 'There was an error fetching the projects. Please try again.', + ); + }); + }); +}); diff --git a/spec/frontend/invite_members/mock_data/api_response_data.js b/spec/frontend/invite_members/mock_data/api_response_data.js new file mode 100644 index 00000000000..9509422b603 --- /dev/null +++ b/spec/frontend/invite_members/mock_data/api_response_data.js @@ -0,0 +1,13 @@ +export const project1 = { + id: 1, + name: 'Project One', + name_with_namespace: 'Project One', + avatar_url: 'test1', +}; +export const project2 = { + id: 2, + name: 'Project One', + name_with_namespace: 'Project Two', + avatar_url: 'test2', +}; +export const allProjects = [project1, project2]; diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js index babe3a66578..bd05cb1ac5a 100644 --- a/spec/frontend/issue_show/components/app_spec.js +++ b/spec/frontend/issue_show/components/app_spec.js @@ -1,7 +1,8 @@ import { GlIntersectionObserver } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import '~/behaviors/markdown/render_gfm'; import IssuableApp from '~/issue_show/components/app.vue'; import DescriptionComponent from '~/issue_show/components/description.vue'; @@ -33,13 +34,17 @@ describe('Issuable output', () => { let realtimeRequestCount = 0; let wrapper; - const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]'); - const findLockedBadge = () => wrapper.find('[data-testid="locked"]'); - const findConfidentialBadge = () => wrapper.find('[data-testid="confidential"]'); + const findStickyHeader = () => wrapper.findByTestId('issue-sticky-header'); + const findLockedBadge = () => wrapper.findByTestId('locked'); + const findConfidentialBadge = () => wrapper.findByTestId('confidential'); + const findHiddenBadge = () => wrapper.findByTestId('hidden'); const findAlert = () => wrapper.find('.alert'); const mountComponent = (props = {}, options = {}, data = {}) => { - wrapper = mount(IssuableApp, { + wrapper = mountExtended(IssuableApp, { + directives: { + GlTooltip: createMockDirective(), + }, propsData: { ...appProps, ...props }, provide: { fullPath: 'gitlab-org/incidents', @@ -539,8 +544,8 @@ describe('Issuable output', () => { it.each` title | isConfidential - ${'does not show confidential badge when issue is not confidential'} | ${true} - ${'shows confidential badge when issue is confidential'} | ${false} + ${'does not show confidential badge when issue is not confidential'} | ${false} + ${'shows confidential badge when issue is confidential'} | ${true} `('$title', async ({ isConfidential }) => { wrapper.setProps({ isConfidential }); @@ -551,8 +556,8 @@ describe('Issuable output', () => { it.each` title | isLocked - ${'does not show locked badge when issue is not locked'} | ${true} - ${'shows locked badge when issue is locked'} | ${false} + ${'does not show locked badge when issue is not locked'} | ${false} + ${'shows locked badge when issue is locked'} | ${true} `('$title', async ({ isLocked }) => { wrapper.setProps({ isLocked }); @@ -560,6 +565,27 @@ describe('Issuable output', () => { expect(findLockedBadge().exists()).toBe(isLocked); }); + + it.each` + title | isHidden + ${'does not show hidden badge when issue is not hidden'} | ${false} + ${'shows hidden badge when issue is hidden'} | ${true} + `('$title', async ({ isHidden }) => { + wrapper.setProps({ isHidden }); + + await nextTick(); + + const hiddenBadge = findHiddenBadge(); + + expect(hiddenBadge.exists()).toBe(isHidden); + + if (isHidden) { + expect(hiddenBadge.attributes('title')).toBe( + 'This issue is hidden because its author has been banned', + ); + expect(getBinding(hiddenBadge.element, 'gl-tooltip')).not.toBeUndefined(); + } + }); }); }); diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues_list/components/issues_list_app_spec.js index 0cb1092135f..8d79a5eed35 100644 --- a/spec/frontend/issues_list/components/issues_list_app_spec.js +++ b/spec/frontend/issues_list/components/issues_list_app_spec.js @@ -5,17 +5,17 @@ import { cloneDeep } from 'lodash'; import { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql'; -import getIssuesCountQuery from 'ee_else_ce/issues_list/queries/get_issues_count.query.graphql'; +import getIssuesCountsQuery from 'ee_else_ce/issues_list/queries/get_issues_counts.query.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import { + getIssuesCountsQueryResponse, getIssuesQueryResponse, filteredTokens, locationSearch, urlParams, - getIssuesCountQueryResponse, } from 'jest/issues_list/mock_data'; import createFlash from '~/flash'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -63,15 +63,15 @@ describe('IssuesListApp component', () => { canBulkUpdate: false, emptyStateSvgPath: 'empty-state.svg', exportCsvPath: 'export/csv/path', + fullPath: 'path/to/project', + hasAnyIssues: true, hasBlockedIssuesFeature: true, hasIssueWeightsFeature: true, hasIterationsFeature: true, - hasProjectIssues: true, + isProject: true, isSignedIn: true, - issuesPath: 'path/to/issues', jiraIntegrationPath: 'jira/integration/path', newIssuePath: 'new/issue/path', - projectPath: 'path/to/project', rssPath: 'rss/path', showNewIssueLink: true, signInPath: 'sign/in/path', @@ -97,12 +97,12 @@ describe('IssuesListApp component', () => { const mountComponent = ({ provide = {}, issuesQueryResponse = jest.fn().mockResolvedValue(defaultQueryResponse), - issuesQueryCountResponse = jest.fn().mockResolvedValue(getIssuesCountQueryResponse), + issuesCountsQueryResponse = jest.fn().mockResolvedValue(getIssuesCountsQueryResponse), mountFn = shallowMount, } = {}) => { const requestHandlers = [ [getIssuesQuery, issuesQueryResponse], - [getIssuesCountQuery, issuesQueryCountResponse], + [getIssuesCountsQuery, issuesCountsQueryResponse], ]; const apolloProvider = createMockApollo(requestHandlers); @@ -134,7 +134,7 @@ describe('IssuesListApp component', () => { it('renders', () => { expect(findIssuableList().props()).toMatchObject({ - namespace: defaultProvide.projectPath, + namespace: defaultProvide.fullPath, recentSearchesStorageKey: 'issues', searchInputPlaceholder: IssuesListApp.i18n.searchPlaceholder, sortOptions: getSortOptions(true, true), @@ -191,7 +191,7 @@ describe('IssuesListApp component', () => { setWindowLocation(search); wrapper = mountComponent({ - provide: { ...defaultProvide, isSignedIn: true }, + provide: { isSignedIn: true }, mountFn: mount, }); @@ -208,7 +208,15 @@ describe('IssuesListApp component', () => { describe('when user is not signed in', () => { it('does not render', () => { - wrapper = mountComponent({ provide: { ...defaultProvide, isSignedIn: false } }); + wrapper = mountComponent({ provide: { isSignedIn: false } }); + + expect(findCsvImportExportButtons().exists()).toBe(false); + }); + }); + + describe('when in a group context', () => { + it('does not render', () => { + wrapper = mountComponent({ provide: { isProject: false } }); expect(findCsvImportExportButtons().exists()).toBe(false); }); @@ -349,7 +357,7 @@ describe('IssuesListApp component', () => { beforeEach(() => { setWindowLocation(`?search=no+results`); - wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount }); + wrapper = mountComponent({ provide: { hasAnyIssues: true }, mountFn: mount }); }); it('shows empty state', () => { @@ -363,7 +371,7 @@ describe('IssuesListApp component', () => { describe('when "Open" tab has no issues', () => { beforeEach(() => { - wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount }); + wrapper = mountComponent({ provide: { hasAnyIssues: true }, mountFn: mount }); }); it('shows empty state', () => { @@ -379,7 +387,7 @@ describe('IssuesListApp component', () => { beforeEach(() => { setWindowLocation(`?state=${IssuableStates.Closed}`); - wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount }); + wrapper = mountComponent({ provide: { hasAnyIssues: true }, mountFn: mount }); }); it('shows empty state', () => { @@ -395,7 +403,7 @@ describe('IssuesListApp component', () => { describe('when user is logged in', () => { beforeEach(() => { wrapper = mountComponent({ - provide: { hasProjectIssues: false, isSignedIn: true }, + provide: { hasAnyIssues: false, isSignedIn: true }, mountFn: mount, }); }); @@ -434,7 +442,7 @@ describe('IssuesListApp component', () => { describe('when user is logged out', () => { beforeEach(() => { wrapper = mountComponent({ - provide: { hasProjectIssues: false, isSignedIn: false }, + provide: { hasAnyIssues: false, isSignedIn: false }, }); }); @@ -571,9 +579,9 @@ describe('IssuesListApp component', () => { describe('errors', () => { describe.each` - error | mountOption | message - ${'fetching issues'} | ${'issuesQueryResponse'} | ${IssuesListApp.i18n.errorFetchingIssues} - ${'fetching issue counts'} | ${'issuesQueryCountResponse'} | ${IssuesListApp.i18n.errorFetchingCounts} + error | mountOption | message + ${'fetching issues'} | ${'issuesQueryResponse'} | ${IssuesListApp.i18n.errorFetchingIssues} + ${'fetching issue counts'} | ${'issuesCountsQueryResponse'} | ${IssuesListApp.i18n.errorFetchingCounts} `('when there is an error $error', ({ mountOption, message }) => { beforeEach(() => { wrapper = mountComponent({ @@ -625,78 +633,99 @@ describe('IssuesListApp component', () => { ...defaultQueryResponse.data.project.issues.nodes[0], id: 'gid://gitlab/Issue/1', iid: '101', - title: 'Issue one', + reference: 'group/project#1', + webPath: '/group/project/-/issues/1', }; const issueTwo = { ...defaultQueryResponse.data.project.issues.nodes[0], id: 'gid://gitlab/Issue/2', iid: '102', - title: 'Issue two', + reference: 'group/project#2', + webPath: '/group/project/-/issues/2', }; const issueThree = { ...defaultQueryResponse.data.project.issues.nodes[0], id: 'gid://gitlab/Issue/3', iid: '103', - title: 'Issue three', + reference: 'group/project#3', + webPath: '/group/project/-/issues/3', }; const issueFour = { ...defaultQueryResponse.data.project.issues.nodes[0], id: 'gid://gitlab/Issue/4', iid: '104', - title: 'Issue four', + reference: 'group/project#4', + webPath: '/group/project/-/issues/4', }; - const response = { + const response = (isProject = true) => ({ data: { - project: { + [isProject ? 'project' : 'group']: { issues: { ...defaultQueryResponse.data.project.issues, nodes: [issueOne, issueTwo, issueThree, issueFour], }, }, }, - }; - - beforeEach(() => { - wrapper = mountComponent({ issuesQueryResponse: jest.fn().mockResolvedValue(response) }); - jest.runOnlyPendingTimers(); }); describe('when successful', () => { - describe.each` - description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId - ${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id} - ${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id} - ${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id} - ${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null} - `( - 'when moving issue $description', - ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => { - it('makes API call to reorder the issue', async () => { - findIssuableList().vm.$emit('reorder', { oldIndex, newIndex }); - - await waitForPromises(); - - expect(axiosMock.history.put[0]).toMatchObject({ - url: joinPaths(defaultProvide.issuesPath, issueToMove.iid, 'reorder'), - data: JSON.stringify({ - move_before_id: getIdFromGraphQLId(moveBeforeId), - move_after_id: getIdFromGraphQLId(moveAfterId), - }), + describe.each([true, false])('when isProject=%s', (isProject) => { + describe.each` + description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId + ${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id} + ${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id} + ${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id} + ${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null} + `( + 'when moving issue $description', + ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { isProject }, + issuesQueryResponse: jest.fn().mockResolvedValue(response(isProject)), + }); + jest.runOnlyPendingTimers(); }); - }); - }, - ); + + it('makes API call to reorder the issue', async () => { + findIssuableList().vm.$emit('reorder', { oldIndex, newIndex }); + + await waitForPromises(); + + expect(axiosMock.history.put[0]).toMatchObject({ + url: joinPaths(issueToMove.webPath, 'reorder'), + data: JSON.stringify({ + move_before_id: getIdFromGraphQLId(moveBeforeId), + move_after_id: getIdFromGraphQLId(moveAfterId), + group_full_path: isProject ? undefined : defaultProvide.fullPath, + }), + }); + }); + }, + ); + }); }); describe('when unsuccessful', () => { + beforeEach(() => { + wrapper = mountComponent({ + issuesQueryResponse: jest.fn().mockResolvedValue(response()), + }); + jest.runOnlyPendingTimers(); + }); + it('displays an error message', async () => { - axiosMock.onPut(joinPaths(defaultProvide.issuesPath, issueOne.iid, 'reorder')).reply(500); + axiosMock.onPut(joinPaths(issueOne.webPath, 'reorder')).reply(500); findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 }); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ message: IssuesListApp.i18n.reorderError }); + expect(createFlash).toHaveBeenCalledWith({ + message: IssuesListApp.i18n.reorderError, + captureError: true, + error: new Error('Request failed with status code 500'), + }); }); }); }); diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues_list/mock_data.js index d3f3f2f9f23..720f9cac986 100644 --- a/spec/frontend/issues_list/mock_data.js +++ b/spec/frontend/issues_list/mock_data.js @@ -29,6 +29,7 @@ export const getIssuesQueryResponse = { updatedAt: '2021-05-22T04:08:01Z', upvotes: 3, userDiscussionsCount: 4, + webPath: 'project/-/issues/789', webUrl: 'project/-/issues/789', assignees: { nodes: [ @@ -70,10 +71,16 @@ export const getIssuesQueryResponse = { }, }; -export const getIssuesCountQueryResponse = { +export const getIssuesCountsQueryResponse = { data: { project: { - issues: { + openedIssues: { + count: 1, + }, + closedIssues: { + count: 1, + }, + allIssues: { count: 1, }, }, diff --git a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap index f2142ce1fcf..891ba9c223c 100644 --- a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap +++ b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap @@ -128,8 +128,26 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = <!----> <div + class="gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-align-items-center gl-px-5" + > + <div + class="gl-display-flex" + > + <!----> + </div> + + <div + class="gl-display-flex" + > + <!----> + </div> + </div> + + <div class="gl-new-dropdown-contents" > + <!----> + <div class="gl-search-box-by-type" > @@ -255,8 +273,26 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = <!----> <div + class="gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-align-items-center gl-px-5" + > + <div + class="gl-display-flex" + > + <!----> + </div> + + <div + class="gl-display-flex" + > + <!----> + </div> + </div> + + <div class="gl-new-dropdown-contents" > + <!----> + <div class="gl-search-box-by-type" > diff --git a/spec/frontend/jobs/components/job_app_spec.js b/spec/frontend/jobs/components/job_app_spec.js index 1f4dd7d6216..f8a0059bf21 100644 --- a/spec/frontend/jobs/components/job_app_spec.js +++ b/spec/frontend/jobs/components/job_app_spec.js @@ -140,7 +140,7 @@ describe('Job App', () => { it('should render provided job information', () => { expect(wrapper.find('.header-main-content').text().replace(/\s+/g, ' ').trim()).toContain( - 'passed Job #4757 triggered 1 year ago by Root', + 'passed Job test triggered 1 year ago by Root', ); }); @@ -154,7 +154,7 @@ describe('Job App', () => { setupAndMount().then(() => { expect( wrapper.find('.header-main-content').text().replace(/\s+/g, ' ').trim(), - ).toContain('passed Job #4757 created 3 weeks ago by Root'); + ).toContain('passed Job test created 3 weeks ago by Root'); })); }); }); diff --git a/spec/frontend/jobs/components/table/cells/actions_cell_spec.js b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js new file mode 100644 index 00000000000..1b1e2d4df8f --- /dev/null +++ b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js @@ -0,0 +1,126 @@ +import { GlModal } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ActionsCell from '~/jobs/components/table/cells/actions_cell.vue'; +import JobPlayMutation from '~/jobs/components/table/graphql/mutations/job_play.mutation.graphql'; +import JobRetryMutation from '~/jobs/components/table/graphql/mutations/job_retry.mutation.graphql'; +import JobUnscheduleMutation from '~/jobs/components/table/graphql/mutations/job_unschedule.mutation.graphql'; +import { playableJob, retryableJob, scheduledJob } from '../../../mock_data'; + +describe('Job actions cell', () => { + let wrapper; + let mutate; + + const findRetryButton = () => wrapper.findByTestId('retry'); + const findPlayButton = () => wrapper.findByTestId('play'); + const findDownloadArtifactsButton = () => wrapper.findByTestId('download-artifacts'); + const findCountdownButton = () => wrapper.findByTestId('countdown'); + const findPlayScheduledJobButton = () => wrapper.findByTestId('play-scheduled'); + const findUnscheduleButton = () => wrapper.findByTestId('unschedule'); + + const findModal = () => wrapper.findComponent(GlModal); + + const MUTATION_SUCCESS = { data: { JobRetryMutation: { jobId: retryableJob.id } } }; + const MUTATION_SUCCESS_UNSCHEDULE = { + data: { JobUnscheduleMutation: { jobId: scheduledJob.id } }, + }; + const MUTATION_SUCCESS_PLAY = { data: { JobPlayMutation: { jobId: playableJob.id } } }; + + const $toast = { + show: jest.fn(), + }; + + const createComponent = (jobType, mutationType = MUTATION_SUCCESS, props = {}) => { + mutate = jest.fn().mockResolvedValue(mutationType); + + wrapper = shallowMountExtended(ActionsCell, { + propsData: { + job: jobType, + ...props, + }, + mocks: { + $apollo: { + mutate, + }, + $toast, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('does not display an artifacts download button', () => { + createComponent(retryableJob); + + expect(findDownloadArtifactsButton().exists()).toBe(false); + }); + + it.each` + button | action | jobType + ${findPlayButton} | ${'play'} | ${playableJob} + ${findRetryButton} | ${'retry'} | ${retryableJob} + ${findDownloadArtifactsButton} | ${'download artifacts'} | ${playableJob} + `('displays the $action button', ({ button, jobType }) => { + createComponent(jobType); + + expect(button().exists()).toBe(true); + }); + + it.each` + button | mutationResult | action | jobType | mutationFile + ${findPlayButton} | ${MUTATION_SUCCESS_PLAY} | ${'play'} | ${playableJob} | ${JobPlayMutation} + ${findRetryButton} | ${MUTATION_SUCCESS} | ${'retry'} | ${retryableJob} | ${JobRetryMutation} + `('performs the $action mutation', ({ button, mutationResult, jobType, mutationFile }) => { + createComponent(jobType, mutationResult); + + button().vm.$emit('click'); + + expect(mutate).toHaveBeenCalledWith({ + mutation: mutationFile, + variables: { + id: jobType.id, + }, + }); + }); + + describe('Scheduled Jobs', () => { + const today = () => new Date('2021-08-31'); + + beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(today); + }); + + it('displays the countdown, play and unschedule buttons', () => { + createComponent(scheduledJob); + + expect(findCountdownButton().exists()).toBe(true); + expect(findPlayScheduledJobButton().exists()).toBe(true); + expect(findUnscheduleButton().exists()).toBe(true); + }); + + it('unschedules a job', () => { + createComponent(scheduledJob, MUTATION_SUCCESS_UNSCHEDULE); + + findUnscheduleButton().vm.$emit('click'); + + expect(mutate).toHaveBeenCalledWith({ + mutation: JobUnscheduleMutation, + variables: { + id: scheduledJob.id, + }, + }); + }); + + it('shows the play job confirmation modal', async () => { + createComponent(scheduledJob, MUTATION_SUCCESS); + + findPlayScheduledJobButton().vm.$emit('click'); + + await nextTick(); + + expect(findModal().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/jobs/components/table/cells.vue/duration_cell_spec.js b/spec/frontend/jobs/components/table/cells/duration_cell_spec.js index 763a4b0eaa2..763a4b0eaa2 100644 --- a/spec/frontend/jobs/components/table/cells.vue/duration_cell_spec.js +++ b/spec/frontend/jobs/components/table/cells/duration_cell_spec.js diff --git a/spec/frontend/jobs/components/table/cells.vue/job_cell_spec.js b/spec/frontend/jobs/components/table/cells/job_cell_spec.js index fc4e5586349..fc4e5586349 100644 --- a/spec/frontend/jobs/components/table/cells.vue/job_cell_spec.js +++ b/spec/frontend/jobs/components/table/cells/job_cell_spec.js diff --git a/spec/frontend/jobs/components/table/cells.vue/pipeline_cell_spec.js b/spec/frontend/jobs/components/table/cells/pipeline_cell_spec.js index 1f5e0a7aa21..1f5e0a7aa21 100644 --- a/spec/frontend/jobs/components/table/cells.vue/pipeline_cell_spec.js +++ b/spec/frontend/jobs/components/table/cells/pipeline_cell_spec.js diff --git a/spec/frontend/jobs/mock_data.js b/spec/frontend/jobs/mock_data.js index 57f0b852ff8..43755b46bc9 100644 --- a/spec/frontend/jobs/mock_data.js +++ b/spec/frontend/jobs/mock_data.js @@ -1555,7 +1555,11 @@ export const mockJobsQueryResponse = { cancelable: false, active: false, stuck: false, - userPermissions: { readBuild: true, __typename: 'JobPermissions' }, + userPermissions: { + readBuild: true, + readJobArtifacts: true, + __typename: 'JobPermissions', + }, __typename: 'CiJob', }, ], @@ -1573,3 +1577,179 @@ export const mockJobsQueryEmptyResponse = { }, }, }; + +export const retryableJob = { + artifacts: { nodes: [], __typename: 'CiJobArtifactConnection' }, + allowFailure: false, + status: 'SUCCESS', + scheduledAt: null, + manualJob: false, + triggered: null, + createdByTag: false, + detailedStatus: { + detailsPath: '/root/test-job-artifacts/-/jobs/1981', + group: 'success', + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed', + action: { + buttonTitle: 'Retry this job', + icon: 'retry', + method: 'post', + path: '/root/test-job-artifacts/-/jobs/1981/retry', + title: 'Retry', + __typename: 'StatusAction', + }, + __typename: 'DetailedStatus', + }, + id: 'gid://gitlab/Ci::Build/1981', + refName: 'main', + refPath: '/root/test-job-artifacts/-/commits/main', + tags: [], + shortSha: '75daf01b', + commitPath: '/root/test-job-artifacts/-/commit/75daf01b465e7eab5a04a315e44660c9a17c8055', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/288', + path: '/root/test-job-artifacts/-/pipelines/288', + user: { + webPath: '/root', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + __typename: 'UserCore', + }, + __typename: 'Pipeline', + }, + stage: { name: 'test', __typename: 'CiStage' }, + name: 'hello_world', + duration: 7, + finishedAt: '2021-08-30T20:33:56Z', + coverage: null, + retryable: true, + playable: false, + cancelable: false, + active: false, + stuck: false, + userPermissions: { readBuild: true, __typename: 'JobPermissions' }, + __typename: 'CiJob', +}; + +export const playableJob = { + artifacts: { + nodes: [ + { + downloadPath: '/root/test-job-artifacts/-/jobs/1982/artifacts/download?file_type=trace', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + allowFailure: false, + status: 'SUCCESS', + scheduledAt: null, + manualJob: true, + triggered: null, + createdByTag: false, + detailedStatus: { + detailsPath: '/root/test-job-artifacts/-/jobs/1982', + group: 'success', + icon: 'status_success', + label: 'manual play action', + text: 'passed', + tooltip: 'passed', + action: { + buttonTitle: 'Trigger this manual action', + icon: 'play', + method: 'post', + path: '/root/test-job-artifacts/-/jobs/1982/play', + title: 'Play', + __typename: 'StatusAction', + }, + __typename: 'DetailedStatus', + }, + id: 'gid://gitlab/Ci::Build/1982', + refName: 'main', + refPath: '/root/test-job-artifacts/-/commits/main', + tags: [], + shortSha: '75daf01b', + commitPath: '/root/test-job-artifacts/-/commit/75daf01b465e7eab5a04a315e44660c9a17c8055', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/288', + path: '/root/test-job-artifacts/-/pipelines/288', + user: { + webPath: '/root', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + __typename: 'UserCore', + }, + __typename: 'Pipeline', + }, + stage: { name: 'test', __typename: 'CiStage' }, + name: 'hello_world_delayed', + duration: 6, + finishedAt: '2021-08-30T20:36:12Z', + coverage: null, + retryable: true, + playable: true, + cancelable: false, + active: false, + stuck: false, + userPermissions: { readBuild: true, readJobArtifacts: true, __typename: 'JobPermissions' }, + __typename: 'CiJob', +}; + +export const scheduledJob = { + artifacts: { nodes: [], __typename: 'CiJobArtifactConnection' }, + allowFailure: false, + status: 'SCHEDULED', + scheduledAt: '2021-08-31T22:36:05Z', + manualJob: true, + triggered: null, + createdByTag: false, + detailedStatus: { + detailsPath: '/root/test-job-artifacts/-/jobs/1986', + group: 'scheduled', + icon: 'status_scheduled', + label: 'unschedule action', + text: 'delayed', + tooltip: 'delayed manual action (%{remainingTime})', + action: { + buttonTitle: 'Unschedule job', + icon: 'time-out', + method: 'post', + path: '/root/test-job-artifacts/-/jobs/1986/unschedule', + title: 'Unschedule', + __typename: 'StatusAction', + }, + __typename: 'DetailedStatus', + }, + id: 'gid://gitlab/Ci::Build/1986', + refName: 'main', + refPath: '/root/test-job-artifacts/-/commits/main', + tags: [], + shortSha: '75daf01b', + commitPath: '/root/test-job-artifacts/-/commit/75daf01b465e7eab5a04a315e44660c9a17c8055', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/290', + path: '/root/test-job-artifacts/-/pipelines/290', + user: { + webPath: '/root', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + __typename: 'UserCore', + }, + __typename: 'Pipeline', + }, + stage: { name: 'test', __typename: 'CiStage' }, + name: 'hello_world_delayed', + duration: null, + finishedAt: null, + coverage: null, + retryable: false, + playable: true, + cancelable: false, + active: false, + stuck: false, + userPermissions: { readBuild: true, __typename: 'JobPermissions' }, + __typename: 'CiJob', +}; diff --git a/spec/frontend/learn_gitlab/track_learn_gitlab_spec.js b/spec/frontend/learn_gitlab/track_learn_gitlab_spec.js deleted file mode 100644 index 3fb38a74c70..00000000000 --- a/spec/frontend/learn_gitlab/track_learn_gitlab_spec.js +++ /dev/null @@ -1,21 +0,0 @@ -import { mockTracking } from 'helpers/tracking_helper'; -import trackLearnGitlab from '~/learn_gitlab/track_learn_gitlab'; - -describe('trackTrialUserErrors', () => { - let spy; - - describe('when an error is present', () => { - beforeEach(() => { - spy = mockTracking('projects:learn_gitlab_index', document.body, jest.spyOn); - }); - - it('tracks the error message', () => { - trackLearnGitlab(); - - expect(spy).toHaveBeenCalledWith('projects:learn_gitlab:index', 'page_init', { - label: 'learn_gitlab', - property: 'Growth::Activation::Experiment::LearnGitLabB', - }); - }); - }); -}); diff --git a/spec/frontend/lib/apollo/instrumentation_link_spec.js b/spec/frontend/lib/apollo/instrumentation_link_spec.js new file mode 100644 index 00000000000..ef686129257 --- /dev/null +++ b/spec/frontend/lib/apollo/instrumentation_link_spec.js @@ -0,0 +1,54 @@ +import { testApolloLink } from 'helpers/test_apollo_link'; +import { getInstrumentationLink, FEATURE_CATEGORY_HEADER } from '~/lib/apollo/instrumentation_link'; + +const TEST_FEATURE_CATEGORY = 'foo_feature'; + +describe('~/lib/apollo/instrumentation_link', () => { + const setFeatureCategory = (val) => { + window.gon.feature_category = val; + }; + + afterEach(() => { + getInstrumentationLink.cache.clear(); + }); + + describe('getInstrumentationLink', () => { + describe('with no gon.feature_category', () => { + beforeEach(() => { + setFeatureCategory(null); + }); + + it('returns null', () => { + expect(getInstrumentationLink()).toBe(null); + }); + }); + + describe('with gon.feature_category', () => { + beforeEach(() => { + setFeatureCategory(TEST_FEATURE_CATEGORY); + }); + + it('returns memoized apollo link', () => { + const result = getInstrumentationLink(); + + // expect.any(ApolloLink) doesn't work for some reason... + expect(result).toHaveProp('request'); + expect(result).toBe(getInstrumentationLink()); + }); + + it('adds a feature category header from the returned apollo link', async () => { + const defaultHeaders = { Authorization: 'foo' }; + const operation = await testApolloLink(getInstrumentationLink(), { + context: { headers: defaultHeaders }, + }); + + const { headers } = operation.getContext(); + + expect(headers).toEqual({ + ...defaultHeaders, + [FEATURE_CATEGORY_HEADER]: TEST_FEATURE_CATEGORY, + }); + }); + }); + }); +}); diff --git a/spec/frontend/lib/dompurify_spec.js b/spec/frontend/lib/dompurify_spec.js index fa8dbb12a08..324441fa2c9 100644 --- a/spec/frontend/lib/dompurify_spec.js +++ b/spec/frontend/lib/dompurify_spec.js @@ -44,6 +44,31 @@ describe('~/lib/dompurify', () => { expect(sanitize('<strong></strong>', { ALLOWED_TAGS: [] })).toBe(''); }); + describe('includes default configuration', () => { + it('with empty config', () => { + const svgIcon = '<svg width="100"><use></use></svg>'; + expect(sanitize(svgIcon, {})).toBe(svgIcon); + }); + + it('with valid config', () => { + expect(sanitize('<a href="#" data-remote="true"></a>', { ALLOWED_TAGS: ['a'] })).toBe( + '<a href="#"></a>', + ); + }); + }); + + it("doesn't sanitize local references", () => { + const htmlHref = `<svg><use href="#some-element"></use></svg>`; + const htmlXlink = `<svg><use xlink:href="#some-element"></use></svg>`; + + expect(sanitize(htmlHref)).toBe(htmlHref); + expect(sanitize(htmlXlink)).toBe(htmlXlink); + }); + + it("doesn't sanitize gl-emoji", () => { + expect(sanitize('<p><gl-emoji>💯</gl-emoji></p>')).toBe('<p><gl-emoji>💯</gl-emoji></p>'); + }); + describe.each` type | gon ${'root'} | ${rootGon} diff --git a/spec/frontend/lib/logger/__snapshots__/hello_spec.js.snap b/spec/frontend/lib/logger/__snapshots__/hello_spec.js.snap new file mode 100644 index 00000000000..791ec05befd --- /dev/null +++ b/spec/frontend/lib/logger/__snapshots__/hello_spec.js.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`~/lib/logger/hello logHello console logs a friendly hello message 1`] = ` +Array [ + Array [ + "%cWelcome to GitLab!%c + +Does this page need fixes or improvements? Open an issue or contribute a merge request to help make GitLab more lovable. At GitLab, everyone can contribute! + +🤝 Contribute to GitLab: https://about.gitlab.com/community/contribute/ +🔎 Create a new GitLab issue: https://gitlab.com/gitlab-org/gitlab/-/issues/new", + "padding-top: 0.5em; font-size: 2em;", + "padding-bottom: 0.5em;", + ], +] +`; diff --git a/spec/frontend/lib/logger/hello_deferred_spec.js b/spec/frontend/lib/logger/hello_deferred_spec.js new file mode 100644 index 00000000000..3233cbff0dc --- /dev/null +++ b/spec/frontend/lib/logger/hello_deferred_spec.js @@ -0,0 +1,17 @@ +import waitForPromises from 'helpers/wait_for_promises'; +import { logHello } from '~/lib/logger/hello'; +import { logHelloDeferred } from '~/lib/logger/hello_deferred'; + +jest.mock('~/lib/logger/hello'); + +describe('~/lib/logger/hello_deferred', () => { + it('dynamically imports and calls logHello', async () => { + logHelloDeferred(); + + expect(logHello).not.toHaveBeenCalled(); + + await waitForPromises(); + + expect(logHello).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/lib/logger/hello_spec.js b/spec/frontend/lib/logger/hello_spec.js new file mode 100644 index 00000000000..39abe0e0dd0 --- /dev/null +++ b/spec/frontend/lib/logger/hello_spec.js @@ -0,0 +1,20 @@ +import { logHello } from '~/lib/logger/hello'; + +describe('~/lib/logger/hello', () => { + let consoleLogSpy; + + beforeEach(() => { + // We don't `mockImplementation` so we can validate there's no errors thrown + consoleLogSpy = jest.spyOn(console, 'log'); + }); + + describe('logHello', () => { + it('console logs a friendly hello message', () => { + expect(consoleLogSpy).not.toHaveBeenCalled(); + + logHello(); + + expect(consoleLogSpy.mock.calls).toMatchSnapshot(); + }); + }); +}); diff --git a/spec/frontend/lib/logger/index_spec.js b/spec/frontend/lib/logger/index_spec.js new file mode 100644 index 00000000000..9382fafe4de --- /dev/null +++ b/spec/frontend/lib/logger/index_spec.js @@ -0,0 +1,23 @@ +import { logError, LOG_PREFIX } from '~/lib/logger'; + +describe('~/lib/logger', () => { + let consoleErrorSpy; + + beforeEach(() => { + consoleErrorSpy = jest.spyOn(console, 'error'); + consoleErrorSpy.mockImplementation(); + }); + + describe('logError', () => { + it('sends given message to console.error', () => { + const message = 'Lorem ipsum dolar sit amit'; + const error = new Error('lorem ipsum'); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + + logError(message, error); + + expect(consoleErrorSpy).toHaveBeenCalledWith(LOG_PREFIX, `${message}\n`, error); + }); + }); +}); diff --git a/spec/frontend/lib/utils/accessor_spec.js b/spec/frontend/lib/utils/accessor_spec.js index 752a88296e6..63497d795ce 100644 --- a/spec/frontend/lib/utils/accessor_spec.js +++ b/spec/frontend/lib/utils/accessor_spec.js @@ -6,60 +6,9 @@ describe('AccessorUtilities', () => { const testError = new Error('test error'); - describe('isPropertyAccessSafe', () => { - let base; - - it('should return `true` if access is safe', () => { - base = { - testProp: 'testProp', - }; - expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(true); - }); - - it('should return `false` if access throws an error', () => { - base = { - get testProp() { - throw testError; - }, - }; - - expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(false); - }); - - it('should return `false` if property is undefined', () => { - base = {}; - - expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(false); - }); - }); - - describe('isFunctionCallSafe', () => { - const base = {}; - - it('should return `true` if calling is safe', () => { - base.func = () => {}; - - expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(true); - }); - - it('should return `false` if calling throws an error', () => { - base.func = () => { - throw new Error('test error'); - }; - - expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(false); - }); - - it('should return `false` if function is undefined', () => { - base.func = undefined; - - expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(false); - }); - }); - - describe('isLocalStorageAccessSafe', () => { + describe('canUseLocalStorage', () => { it('should return `true` if access is safe', () => { - expect(AccessorUtilities.isLocalStorageAccessSafe()).toBe(true); + expect(AccessorUtilities.canUseLocalStorage()).toBe(true); }); it('should return `false` if access to .setItem isnt safe', () => { @@ -67,19 +16,19 @@ describe('AccessorUtilities', () => { throw testError; }); - expect(AccessorUtilities.isLocalStorageAccessSafe()).toBe(false); + expect(AccessorUtilities.canUseLocalStorage()).toBe(false); }); it('should set a test item if access is safe', () => { - AccessorUtilities.isLocalStorageAccessSafe(); + AccessorUtilities.canUseLocalStorage(); - expect(window.localStorage.setItem).toHaveBeenCalledWith('isLocalStorageAccessSafe', 'true'); + expect(window.localStorage.setItem).toHaveBeenCalledWith('canUseLocalStorage', 'true'); }); it('should remove the test item if access is safe', () => { - AccessorUtilities.isLocalStorageAccessSafe(); + AccessorUtilities.canUseLocalStorage(); - expect(window.localStorage.removeItem).toHaveBeenCalledWith('isLocalStorageAccessSafe'); + expect(window.localStorage.removeItem).toHaveBeenCalledWith('canUseLocalStorage'); }); }); }); diff --git a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js new file mode 100644 index 00000000000..942ba56196e --- /dev/null +++ b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js @@ -0,0 +1,120 @@ +import * as utils from '~/lib/utils/datetime/date_format_utility'; + +describe('date_format_utility.js', () => { + describe('padWithZeros', () => { + it.each` + input | output + ${0} | ${'00'} + ${'1'} | ${'01'} + ${'10'} | ${'10'} + ${'100'} | ${'100'} + ${100} | ${'100'} + ${'a'} | ${'0a'} + ${'foo'} | ${'foo'} + `('properly pads $input to match $output', ({ input, output }) => { + expect(utils.padWithZeros(input)).toEqual([output]); + }); + + it('accepts multiple arguments', () => { + expect(utils.padWithZeros(1, '2', 3)).toEqual(['01', '02', '03']); + }); + + it('returns an empty array provided no argument', () => { + expect(utils.padWithZeros()).toEqual([]); + }); + }); + + describe('stripTimezoneFromISODate', () => { + it.each` + input | expectedOutput + ${'2021-08-16T00:00:00Z'} | ${'2021-08-16T00:00:00'} + ${'2021-08-16T10:30:00+02:00'} | ${'2021-08-16T10:30:00'} + ${'2021-08-16T10:30:00-05:30'} | ${'2021-08-16T10:30:00'} + `('returns $expectedOutput when given $input', ({ input, expectedOutput }) => { + expect(utils.stripTimezoneFromISODate(input)).toBe(expectedOutput); + }); + + it('returns null if date is invalid', () => { + expect(utils.stripTimezoneFromISODate('Invalid date')).toBe(null); + }); + }); + + describe('dateToYearMonthDate', () => { + it.each` + date | expectedOutput + ${new Date('2021-08-05')} | ${{ year: '2021', month: '08', day: '05' }} + ${new Date('2021-12-24')} | ${{ year: '2021', month: '12', day: '24' }} + `('returns $expectedOutput provided $date', ({ date, expectedOutput }) => { + expect(utils.dateToYearMonthDate(date)).toEqual(expectedOutput); + }); + + it('throws provided an invalid date', () => { + expect(() => utils.dateToYearMonthDate('Invalid date')).toThrow( + 'Argument should be a Date instance', + ); + }); + }); + + describe('timeToHoursMinutes', () => { + it.each` + time | expectedOutput + ${'23:12'} | ${{ hours: '23', minutes: '12' }} + ${'23:12'} | ${{ hours: '23', minutes: '12' }} + `('returns $expectedOutput provided $time', ({ time, expectedOutput }) => { + expect(utils.timeToHoursMinutes(time)).toEqual(expectedOutput); + }); + + it('throws provided an invalid time', () => { + expect(() => utils.timeToHoursMinutes('Invalid time')).toThrow('Invalid time provided'); + }); + }); + + describe('dateAndTimeToISOString', () => { + it('computes the date properly', () => { + expect(utils.dateAndTimeToISOString(new Date('2021-08-16'), '10:00')).toBe( + '2021-08-16T10:00:00.000Z', + ); + }); + + it('computes the date properly with an offset', () => { + expect(utils.dateAndTimeToISOString(new Date('2021-08-16'), '10:00', '-04:00')).toBe( + '2021-08-16T10:00:00.000-04:00', + ); + }); + + it('throws if date in invalid', () => { + expect(() => utils.dateAndTimeToISOString('Invalid date', '10:00')).toThrow( + 'Argument should be a Date instance', + ); + }); + + it('throws if time in invalid', () => { + expect(() => utils.dateAndTimeToISOString(new Date('2021-08-16'), '')).toThrow( + 'Invalid time provided', + ); + }); + + it('throws if offset is invalid', () => { + expect(() => + utils.dateAndTimeToISOString(new Date('2021-08-16'), '10:00', 'not an offset'), + ).toThrow('Could not initialize date'); + }); + }); + + describe('dateToTimeInputValue', () => { + it.each` + input | expectedOutput + ${new Date('2021-08-16T10:00:00.000Z')} | ${'10:00'} + ${new Date('2021-08-16T22:30:00.000Z')} | ${'22:30'} + ${new Date('2021-08-16T22:30:00.000-03:00')} | ${'01:30'} + `('extracts $expectedOutput out of $input', ({ input, expectedOutput }) => { + expect(utils.dateToTimeInputValue(input)).toBe(expectedOutput); + }); + + it('throws if date is invalid', () => { + expect(() => utils.dateToTimeInputValue('Invalid date')).toThrow( + 'Argument should be a Date instance', + ); + }); + }); +}); diff --git a/spec/frontend/lib/utils/dom_utils_spec.js b/spec/frontend/lib/utils/dom_utils_spec.js index 7c4c20e651f..cb8b1c7ca9a 100644 --- a/spec/frontend/lib/utils/dom_utils_spec.js +++ b/spec/frontend/lib/utils/dom_utils_spec.js @@ -5,6 +5,7 @@ import { parseBooleanDataAttributes, isElementVisible, isElementHidden, + getParents, } from '~/lib/utils/dom_utils'; const TEST_MARGIN = 5; @@ -193,4 +194,18 @@ describe('DOM Utils', () => { }); }, ); + + describe('getParents', () => { + it('gets all parents of an element', () => { + const el = document.createElement('div'); + el.innerHTML = '<p><span><strong><mark>hello world'; + + expect(getParents(el.querySelector('mark'))).toEqual([ + el.querySelector('strong'), + el.querySelector('span'), + el.querySelector('p'), + el, + ]); + }); + }); }); diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js index beedb9b2eba..acbf1a975b8 100644 --- a/spec/frontend/lib/utils/text_markdown_spec.js +++ b/spec/frontend/lib/utils/text_markdown_spec.js @@ -88,6 +88,25 @@ describe('init markdown', () => { expect(textArea.value).toEqual(`${initialValue}\n- `); }); + it('unescapes new line characters', () => { + const initialValue = ''; + + textArea.value = initialValue; + textArea.selectionStart = 0; + textArea.selectionEnd = 0; + + insertMarkdownText({ + textArea, + text: textArea.value, + tag: '```suggestion:-0+0\n{text}\n```', + blockTag: true, + selected: '# Does not parse the %br currently.', + wrap: false, + }); + + expect(textArea.value).toContain('# Does not parse the \\n currently.'); + }); + it('inserts the tag on the same line if the current line only contains spaces', () => { const initialValue = ' '; diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index c8ac7ffc9d9..6f186ba3227 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -645,29 +645,6 @@ describe('URL utility', () => { }); }); - describe('urlParamsToObject', () => { - it('parses path for label with trailing +', () => { - // eslint-disable-next-line import/no-deprecated - expect(urlUtils.urlParamsToObject('label_name[]=label%2B', {})).toEqual({ - label_name: ['label+'], - }); - }); - - it('parses path for milestone with trailing +', () => { - // eslint-disable-next-line import/no-deprecated - expect(urlUtils.urlParamsToObject('milestone_title=A%2B', {})).toEqual({ - milestone_title: 'A+', - }); - }); - - it('parses path for search terms with spaces', () => { - // eslint-disable-next-line import/no-deprecated - expect(urlUtils.urlParamsToObject('search=two+words', {})).toEqual({ - search: 'two words', - }); - }); - }); - describe('queryToObject', () => { it.each` case | query | options | result diff --git a/spec/frontend/members/components/modals/leave_modal_spec.js b/spec/frontend/members/components/modals/leave_modal_spec.js index ea9eb7bf923..1dc913e5c78 100644 --- a/spec/frontend/members/components/modals/leave_modal_spec.js +++ b/spec/frontend/members/components/modals/leave_modal_spec.js @@ -99,10 +99,14 @@ describe('LeaveModal', () => { }); }); - it("does NOT display oncall schedules list when member's user is NOT a part of on-call schedules ", () => { + it("does NOT display oncall schedules list when member's user is NOT a part of on-call schedules ", async () => { + wrapper.destroy(); + const memberWithoutOncallSchedules = cloneDeep(member); - delete (memberWithoutOncallSchedules, 'user.oncallSchedules'); + delete memberWithoutOncallSchedules.user.oncallSchedules; createComponent({ member: memberWithoutOncallSchedules }); + await nextTick(); + expect(findOncallSchedulesList().exists()).toBe(false); }); }); diff --git a/spec/frontend/merge_request_tabs_spec.js b/spec/frontend/merge_request_tabs_spec.js index 23e9bf8b447..ced9b71125b 100644 --- a/spec/frontend/merge_request_tabs_spec.js +++ b/spec/frontend/merge_request_tabs_spec.js @@ -34,6 +34,44 @@ describe('MergeRequestTabs', () => { gl.mrWidget = {}; }); + describe('clickTab', () => { + let params; + + beforeEach(() => { + document.documentElement.scrollTop = 100; + + params = { + metaKey: false, + ctrlKey: false, + which: 1, + stopImmediatePropagation() {}, + preventDefault() {}, + currentTarget: { + getAttribute(attr) { + return attr === 'href' ? 'a/tab/url' : null; + }, + }, + }; + }); + + it("stores the current scroll position if there's an active tab", () => { + testContext.class.currentTab = 'someTab'; + + testContext.class.clickTab(params); + + expect(testContext.class.scrollPositions.someTab).toBe(100); + }); + + it("doesn't store a scroll position if there's no active tab", () => { + // this happens on first load, and we just don't want to store empty values in the `null` property + testContext.class.currentTab = null; + + testContext.class.clickTab(params); + + expect(testContext.class.scrollPositions).toEqual({}); + }); + }); + describe('opensInNewTab', () => { const windowTarget = '_blank'; let clickTabParams; @@ -258,6 +296,7 @@ describe('MergeRequestTabs', () => { beforeEach(() => { jest.spyOn(mainContent, 'getBoundingClientRect').mockReturnValue({ top: 10 }); jest.spyOn(tabContent, 'getBoundingClientRect').mockReturnValue({ top: 100 }); + jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); jest.spyOn(document, 'querySelector').mockImplementation((selector) => { return selector === '.content-wrapper' ? mainContent : tabContent; }); @@ -267,8 +306,6 @@ describe('MergeRequestTabs', () => { it('calls window scrollTo with options if document has scrollBehavior', () => { document.documentElement.style.scrollBehavior = ''; - jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); - testContext.class.tabShown('commits', 'foobar'); expect(window.scrollTo.mock.calls[0][0]).toEqual({ top: 39, behavior: 'smooth' }); @@ -276,11 +313,50 @@ describe('MergeRequestTabs', () => { it('calls window scrollTo with two args if document does not have scrollBehavior', () => { jest.spyOn(document.documentElement, 'style', 'get').mockReturnValue({}); - jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); testContext.class.tabShown('commits', 'foobar'); expect(window.scrollTo.mock.calls[0]).toEqual([0, 39]); }); + + describe('when switching tabs', () => { + const SCROLL_TOP = 100; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + beforeEach(() => { + jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); + testContext.class.mergeRequestTabs = document.createElement('div'); + testContext.class.mergeRequestTabPanes = document.createElement('div'); + testContext.class.currentTab = 'tab'; + testContext.class.scrollPositions = { newTab: SCROLL_TOP }; + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('scrolls to the stored position, if one is stored', () => { + testContext.class.tabShown('newTab'); + + jest.advanceTimersByTime(250); + + expect(window.scrollTo.mock.calls[0][0]).toEqual({ + top: SCROLL_TOP, + left: 0, + behavior: 'auto', + }); + }); + + it('scrolls to 0, if no position is stored', () => { + testContext.class.tabShown('unknownTab'); + + jest.advanceTimersByTime(250); + + expect(window.scrollTo.mock.calls[0][0]).toEqual({ top: 0, left: 0, behavior: 'auto' }); + }); + }); }); }); diff --git a/spec/frontend/milestones/stores/mutations_spec.js b/spec/frontend/milestones/stores/mutations_spec.js index 91b2acf23c5..a53d6ca5de1 100644 --- a/spec/frontend/milestones/stores/mutations_spec.js +++ b/spec/frontend/milestones/stores/mutations_spec.js @@ -174,6 +174,35 @@ describe('Milestones combobox Vuex store mutations', () => { }); }); + it('falls back to the length of list if pagination headers are missing', () => { + const response = { + data: [ + { + title: 'v0.1', + }, + { + title: 'v0.2', + }, + ], + headers: {}, + }; + + mutations[types.RECEIVE_PROJECT_MILESTONES_SUCCESS](state, response); + + expect(state.matches.projectMilestones).toEqual({ + list: [ + { + title: 'v0.1', + }, + { + title: 'v0.2', + }, + ], + error: null, + totalCount: 2, + }); + }); + describe(`${types.RECEIVE_PROJECT_MILESTONES_ERROR}`, () => { it('updates state.matches.projectMilestones to an empty state with the error object', () => { const error = new Error('Something went wrong!'); @@ -227,6 +256,35 @@ describe('Milestones combobox Vuex store mutations', () => { }); }); + it('falls back to the length of data received if pagination headers are missing', () => { + const response = { + data: [ + { + title: 'group-0.1', + }, + { + title: 'group-0.2', + }, + ], + headers: {}, + }; + + mutations[types.RECEIVE_GROUP_MILESTONES_SUCCESS](state, response); + + expect(state.matches.groupMilestones).toEqual({ + list: [ + { + title: 'group-0.1', + }, + { + title: 'group-0.2', + }, + ], + error: null, + totalCount: 2, + }); + }); + describe(`${types.RECEIVE_GROUP_MILESTONES_ERROR}`, () => { it('updates state.matches.groupMilestones to an empty state with the error object', () => { const error = new Error('Something went wrong!'); diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap index 08f9e07244f..05538dbaeee 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -36,11 +36,15 @@ exports[`Dashboard template matches the default snapshot 1`] = ` <gl-dropdown-stub category="primary" class="flex-grow-1" + clearalltext="Clear all" data-qa-selector="environments_dropdown" headertext="" hideheaderborder="true" + highlighteditemstitle="Selected" + highlighteditemstitleclass="gl-px-5" id="monitor-environments-dropdown" menu-class="monitor-environment-dropdown-menu" + showhighlighteditemstitle="true" size="medium" text="production" toggleclass="dropdown-menu-toggle" diff --git a/spec/frontend/notebook/cells/markdown_spec.js b/spec/frontend/notebook/cells/markdown_spec.js index deeee5d6589..707efa21528 100644 --- a/spec/frontend/notebook/cells/markdown_spec.js +++ b/spec/frontend/notebook/cells/markdown_spec.js @@ -1,3 +1,4 @@ +import { mount } from '@vue/test-utils'; import katex from 'katex'; import Vue from 'vue'; import MarkdownComponent from '~/notebook/cells/markdown.vue'; @@ -6,6 +7,28 @@ const Component = Vue.extend(MarkdownComponent); window.katex = katex; +function buildCellComponent(cell, relativePath = '') { + return mount(Component, { + propsData: { + cell, + }, + provide: { + relativeRawPath: relativePath, + }, + }).vm; +} + +function buildMarkdownComponent(markdownContent, relativePath = '') { + return buildCellComponent( + { + cell_type: 'markdown', + metadata: {}, + source: markdownContent, + }, + relativePath, + ); +} + describe('Markdown component', () => { let vm; let cell; @@ -17,12 +40,7 @@ describe('Markdown component', () => { // eslint-disable-next-line prefer-destructuring cell = json.cells[1]; - vm = new Component({ - propsData: { - cell, - }, - }); - vm.$mount(); + vm = buildCellComponent(cell); return vm.$nextTick(); }); @@ -61,17 +79,36 @@ describe('Markdown component', () => { expect(findLink().getAttribute('data-type')).toBe(null); }); + describe('When parsing images', () => { + it.each([ + [ + 'for relative images in root folder, it does', + '![](local_image.png)\n', + 'src="/raw/local_image', + ], + [ + 'for relative images in child folders, it does', + '![](data/local_image.png)\n', + 'src="/raw/data', + ], + ["for embedded images, it doesn't", '![](data:image/jpeg;base64)\n', 'src="data:'], + ["for images urls, it doesn't", '![](http://image.png)\n', 'src="http:'], + ])('%s', async ([testMd, mustContain]) => { + vm = buildMarkdownComponent([testMd], '/raw/'); + + await vm.$nextTick(); + + expect(vm.$el.innerHTML).toContain(mustContain); + }); + }); + describe('tables', () => { beforeEach(() => { json = getJSONFixture('blob/notebook/markdown-table.json'); }); it('renders images and text', () => { - vm = new Component({ - propsData: { - cell: json.cells[0], - }, - }).$mount(); + vm = buildCellComponent(json.cells[0]); return vm.$nextTick().then(() => { const images = vm.$el.querySelectorAll('img'); @@ -102,48 +139,28 @@ describe('Markdown component', () => { }); it('renders multi-line katex', async () => { - vm = new Component({ - propsData: { - cell: json.cells[0], - }, - }).$mount(); + vm = buildCellComponent(json.cells[0]); await vm.$nextTick(); expect(vm.$el.querySelector('.katex')).not.toBeNull(); }); it('renders inline katex', async () => { - vm = new Component({ - propsData: { - cell: json.cells[1], - }, - }).$mount(); + vm = buildCellComponent(json.cells[1]); await vm.$nextTick(); expect(vm.$el.querySelector('p:first-child .katex')).not.toBeNull(); }); it('renders multiple inline katex', async () => { - vm = new Component({ - propsData: { - cell: json.cells[1], - }, - }).$mount(); + vm = buildCellComponent(json.cells[1]); await vm.$nextTick(); expect(vm.$el.querySelectorAll('p:nth-child(2) .katex')).toHaveLength(4); }); it('output cell in case of katex error', async () => { - vm = new Component({ - propsData: { - cell: { - cell_type: 'markdown', - metadata: {}, - source: ['Some invalid $a & b$ inline formula $b & c$\n', '\n'], - }, - }, - }).$mount(); + vm = buildMarkdownComponent(['Some invalid $a & b$ inline formula $b & c$\n', '\n']); await vm.$nextTick(); // expect one paragraph with no katex formula in it @@ -152,15 +169,10 @@ describe('Markdown component', () => { }); it('output cell and render remaining formula in case of katex error', async () => { - vm = new Component({ - propsData: { - cell: { - cell_type: 'markdown', - metadata: {}, - source: ['An invalid $a & b$ inline formula and a vaild one $b = c$\n', '\n'], - }, - }, - }).$mount(); + vm = buildMarkdownComponent([ + 'An invalid $a & b$ inline formula and a vaild one $b = c$\n', + '\n', + ]); await vm.$nextTick(); // expect one paragraph with no katex formula in it @@ -169,15 +181,7 @@ describe('Markdown component', () => { }); it('renders math formula in list object', async () => { - vm = new Component({ - propsData: { - cell: { - cell_type: 'markdown', - metadata: {}, - source: ["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n'], - }, - }, - }).$mount(); + vm = buildMarkdownComponent(["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n']); await vm.$nextTick(); // expect one list with a katex formula in it @@ -186,15 +190,7 @@ describe('Markdown component', () => { }); it("renders math formula with tick ' in it", async () => { - vm = new Component({ - propsData: { - cell: { - cell_type: 'markdown', - metadata: {}, - source: ["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n'], - }, - }, - }).$mount(); + vm = buildMarkdownComponent(["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n']); await vm.$nextTick(); // expect one list with a katex formula in it @@ -203,15 +199,7 @@ describe('Markdown component', () => { }); it('renders math formula with less-than-operator < in it', async () => { - vm = new Component({ - propsData: { - cell: { - cell_type: 'markdown', - metadata: {}, - source: ['- list with inline $a=2$ inline formula $a + b < c$\n', '\n'], - }, - }, - }).$mount(); + vm = buildMarkdownComponent(['- list with inline $a=2$ inline formula $a + b < c$\n', '\n']); await vm.$nextTick(); // expect one list with a katex formula in it @@ -220,15 +208,7 @@ describe('Markdown component', () => { }); it('renders math formula with greater-than-operator > in it', async () => { - vm = new Component({ - propsData: { - cell: { - cell_type: 'markdown', - metadata: {}, - source: ['- list with inline $a=2$ inline formula $a + b > c$\n', '\n'], - }, - }, - }).$mount(); + vm = buildMarkdownComponent(['- list with inline $a=2$ inline formula $a + b > c$\n', '\n']); await vm.$nextTick(); // expect one list with a katex formula in it diff --git a/spec/frontend/notebook/cells/output/html_sanitize_fixtures.js b/spec/frontend/notebook/cells/output/html_sanitize_fixtures.js index 0b585ab860b..803ac4a219d 100644 --- a/spec/frontend/notebook/cells/output/html_sanitize_fixtures.js +++ b/spec/frontend/notebook/cells/output/html_sanitize_fixtures.js @@ -90,7 +90,8 @@ export default [ ' </g>\n', '</svg>', ].join(), - output: '<svg height="115.02pt" id="svg2"', + output: + '<svg xmlns="http://www.w3.org/2000/svg" width="388.84pt" version="1.0" id="svg2" height="115.02pt">', }, ], ]; diff --git a/spec/frontend/notebook/index_spec.js b/spec/frontend/notebook/index_spec.js index 945af08e4d5..4d0dacaf37e 100644 --- a/spec/frontend/notebook/index_spec.js +++ b/spec/frontend/notebook/index_spec.js @@ -1,3 +1,4 @@ +import { mount } from '@vue/test-utils'; import Vue from 'vue'; import Notebook from '~/notebook/index.vue'; @@ -13,14 +14,16 @@ describe('Notebook component', () => { jsonWithWorksheet = getJSONFixture('blob/notebook/worksheets.json'); }); + function buildComponent(notebook) { + return mount(Component, { + propsData: { notebook, codeCssClass: 'js-code-class' }, + provide: { relativeRawPath: '' }, + }).vm; + } + describe('without JSON', () => { beforeEach((done) => { - vm = new Component({ - propsData: { - notebook: {}, - }, - }); - vm.$mount(); + vm = buildComponent({}); setImmediate(() => { done(); @@ -34,13 +37,7 @@ describe('Notebook component', () => { describe('with JSON', () => { beforeEach((done) => { - vm = new Component({ - propsData: { - notebook: json, - codeCssClass: 'js-code-class', - }, - }); - vm.$mount(); + vm = buildComponent(json); setImmediate(() => { done(); @@ -66,13 +63,7 @@ describe('Notebook component', () => { describe('with worksheets', () => { beforeEach((done) => { - vm = new Component({ - propsData: { - notebook: jsonWithWorksheet, - codeCssClass: 'js-code-class', - }, - }); - vm.$mount(); + vm = buildComponent(jsonWithWorksheet); setImmediate(() => { done(); diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index bb79b43205b..c3a51c51de0 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -10,6 +10,7 @@ import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import CommentForm from '~/notes/components/comment_form.vue'; +import CommentTypeDropdown from '~/notes/components/comment_type_dropdown.vue'; import * as constants from '~/notes/constants'; import eventHub from '~/notes/event_hub'; import { COMMENT_FORM } from '~/notes/i18n'; @@ -33,8 +34,8 @@ describe('issue_comment_form component', () => { const findAddToReviewButton = () => wrapper.findByTestId('add-to-review-button'); const findAddCommentNowButton = () => wrapper.findByTestId('add-comment-now-button'); const findConfidentialNoteCheckbox = () => wrapper.findByTestId('confidential-note-checkbox'); - const findCommentGlDropdown = () => wrapper.findByTestId('comment-button'); - const findCommentButton = () => findCommentGlDropdown().find('button'); + const findCommentTypeDropdown = () => wrapper.findComponent(CommentTypeDropdown); + const findCommentButton = () => findCommentTypeDropdown().find('button'); const findErrorAlerts = () => wrapper.findAllComponents(GlAlert).wrappers; async function clickCommentButton({ waitForComponent = true, waitForNetwork = true } = {}) { @@ -381,7 +382,7 @@ describe('issue_comment_form component', () => { it('should render comment button as disabled', () => { mountComponent(); - expect(findCommentGlDropdown().props('disabled')).toBe(true); + expect(findCommentTypeDropdown().props('disabled')).toBe(true); }); it('should enable comment button if it has note', async () => { @@ -389,7 +390,7 @@ describe('issue_comment_form component', () => { await wrapper.setData({ note: 'Foo' }); - expect(findCommentGlDropdown().props('disabled')).toBe(false); + expect(findCommentTypeDropdown().props('disabled')).toBe(false); }); it('should update buttons texts when it has note', () => { @@ -624,7 +625,7 @@ describe('issue_comment_form component', () => { it('when no drafts exist, should not render', () => { mountComponent(); - expect(findCommentGlDropdown().exists()).toBe(true); + expect(findCommentTypeDropdown().exists()).toBe(true); expect(findAddToReviewButton().exists()).toBe(false); expect(findAddCommentNowButton().exists()).toBe(false); }); @@ -637,7 +638,7 @@ describe('issue_comment_form component', () => { it('should render', () => { mountComponent(); - expect(findCommentGlDropdown().exists()).toBe(false); + expect(findCommentTypeDropdown().exists()).toBe(false); expect(findAddToReviewButton().exists()).toBe(true); expect(findAddCommentNowButton().exists()).toBe(true); }); diff --git a/spec/frontend/notes/components/comment_type_dropdown_spec.js b/spec/frontend/notes/components/comment_type_dropdown_spec.js new file mode 100644 index 00000000000..5e1cb813369 --- /dev/null +++ b/spec/frontend/notes/components/comment_type_dropdown_spec.js @@ -0,0 +1,64 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import CommentTypeDropdown from '~/notes/components/comment_type_dropdown.vue'; +import * as constants from '~/notes/constants'; +import { COMMENT_FORM } from '~/notes/i18n'; + +describe('CommentTypeDropdown component', () => { + let wrapper; + + const findCommentGlDropdown = () => wrapper.findComponent(GlDropdown); + const findCommentDropdownOption = () => wrapper.findAllComponents(GlDropdownItem).at(0); + const findDiscussionDropdownOption = () => wrapper.findAllComponents(GlDropdownItem).at(1); + + const mountComponent = ({ props = {} } = {}) => { + wrapper = extendedWrapper( + mount(CommentTypeDropdown, { + propsData: { + noteableDisplayName: 'issue', + noteType: constants.COMMENT, + ...props, + }, + }), + ); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('Should label action button "Comment" and correct dropdown item checked when selected', () => { + mountComponent({ props: { noteType: constants.COMMENT } }); + + expect(findCommentGlDropdown().props()).toMatchObject({ text: COMMENT_FORM.comment }); + expect(findCommentDropdownOption().props()).toMatchObject({ isChecked: true }); + expect(findDiscussionDropdownOption().props()).toMatchObject({ isChecked: false }); + }); + + it('Should label action button "Start Thread" and correct dropdown item option checked when selected', () => { + mountComponent({ props: { noteType: constants.DISCUSSION } }); + + expect(findCommentGlDropdown().props()).toMatchObject({ text: COMMENT_FORM.startThread }); + expect(findCommentDropdownOption().props()).toMatchObject({ isChecked: false }); + expect(findDiscussionDropdownOption().props()).toMatchObject({ isChecked: true }); + }); + + it('Should emit `change` event when clicking on an alternate dropdown option', () => { + mountComponent({ props: { noteType: constants.DISCUSSION } }); + + findCommentDropdownOption().vm.$emit('click'); + findDiscussionDropdownOption().vm.$emit('click'); + + expect(wrapper.emitted('change')[0]).toEqual([constants.COMMENT]); + expect(wrapper.emitted('change').length).toEqual(1); + }); + + it('Should emit `click` event when clicking on the action button', () => { + mountComponent({ props: { noteType: constants.DISCUSSION } }); + + findCommentGlDropdown().vm.$emit('click'); + + expect(wrapper.emitted('click').length > 0).toBe(true); + }); +}); diff --git a/spec/frontend/notes/old_notes_spec.js b/spec/frontend/notes/deprecated_notes_spec.js index 0cf43b8fd97..34623f8aa13 100644 --- a/spec/frontend/notes/old_notes_spec.js +++ b/spec/frontend/notes/deprecated_notes_spec.js @@ -14,9 +14,8 @@ import * as urlUtility from '~/lib/utils/url_utility'; window.jQuery = $; require('autosize'); require('~/commons'); -require('~/notes'); +const Notes = require('~/deprecated_notes').default; -const { Notes } = window; const FLASH_TYPE_ALERT = 'alert'; const NOTES_POST_PATH = /(.*)\/notes\?html=true$/; const fixture = 'snippets/show.html'; @@ -31,7 +30,7 @@ gl.utils.disableButtonIfEmptyField = () => {}; // the following test is unreliable and failing in main 2-3 times a day // see https://gitlab.com/gitlab-org/gitlab/issues/206906#note_290602581 // eslint-disable-next-line jest/no-disabled-tests -describe.skip('Old Notes (~/notes.js)', () => { +describe.skip('Old Notes (~/deprecated_notes.js)', () => { beforeEach(() => { loadFixtures(fixture); @@ -67,7 +66,7 @@ describe.skip('Old Notes (~/notes.js)', () => { it('calls postComment when comment button is clicked', () => { jest.spyOn(Notes.prototype, 'postComment'); - new window.Notes('', []); + new Notes('', []); $('.js-comment-button').click(); expect(Notes.prototype.postComment).toHaveBeenCalled(); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap index 45d261625b4..451cf743e35 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap @@ -177,15 +177,6 @@ exports[`PackageTitle renders without tags 1`] = ` texttooltip="" /> </div> - <div - class="gl-display-flex gl-align-items-center gl-mr-5" - > - <package-tags-stub - hidelabel="true" - tagdisplaylimit="2" - tags="[object Object],[object Object],[object Object]" - /> - </div> </div> </div> diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js index 0504a42dfcf..7a71a1cea0f 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js @@ -1,10 +1,11 @@ -import { GlLink, GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { conanMetadata, mavenMetadata, nugetMetadata, packageData, + composerMetadata, + pypiMetadata, } from 'jest/packages_and_registries/package_registry/mock_data'; import component from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue'; import { @@ -12,12 +13,15 @@ import { PACKAGE_TYPE_CONAN, PACKAGE_TYPE_MAVEN, PACKAGE_TYPE_NPM, + PACKAGE_TYPE_COMPOSER, + PACKAGE_TYPE_PYPI, } from '~/packages_and_registries/package_registry/constants'; -import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; const mavenPackage = { packageType: PACKAGE_TYPE_MAVEN, metadata: mavenMetadata() }; const conanPackage = { packageType: PACKAGE_TYPE_CONAN, metadata: conanMetadata() }; const nugetPackage = { packageType: PACKAGE_TYPE_NUGET, metadata: nugetMetadata() }; +const composerPackage = { packageType: PACKAGE_TYPE_COMPOSER, metadata: composerMetadata() }; +const pypiPackage = { packageType: PACKAGE_TYPE_PYPI, metadata: pypiMetadata() }; const npmPackage = { packageType: PACKAGE_TYPE_NPM, metadata: {} }; describe('Package Additional Metadata', () => { @@ -32,8 +36,7 @@ describe('Package Additional Metadata', () => { wrapper = shallowMountExtended(component, { propsData: { ...defaultProps, ...props }, stubs: { - DetailsRow, - GlSprintf, + component: { template: '<div data-testid="component-is"></div>' }, }, }); }; @@ -45,12 +48,7 @@ describe('Package Additional Metadata', () => { const findTitle = () => wrapper.findByTestId('title'); const findMainArea = () => wrapper.findByTestId('main'); - const findNugetSource = () => wrapper.findByTestId('nuget-source'); - const findNugetLicense = () => wrapper.findByTestId('nuget-license'); - const findConanRecipe = () => wrapper.findByTestId('conan-recipe'); - const findMavenApp = () => wrapper.findByTestId('maven-app'); - const findMavenGroup = () => wrapper.findByTestId('maven-group'); - const findElementLink = (container) => container.findComponent(GlLink); + const findComponentIs = () => wrapper.findByTestId('component-is'); it('has the correct title', () => { mountComponent(); @@ -62,11 +60,13 @@ describe('Package Additional Metadata', () => { }); it.each` - packageEntity | visible | packageType - ${mavenPackage} | ${true} | ${PACKAGE_TYPE_MAVEN} - ${conanPackage} | ${true} | ${PACKAGE_TYPE_CONAN} - ${nugetPackage} | ${true} | ${PACKAGE_TYPE_NUGET} - ${npmPackage} | ${false} | ${PACKAGE_TYPE_NPM} + packageEntity | visible | packageType + ${mavenPackage} | ${true} | ${PACKAGE_TYPE_MAVEN} + ${conanPackage} | ${true} | ${PACKAGE_TYPE_CONAN} + ${nugetPackage} | ${true} | ${PACKAGE_TYPE_NUGET} + ${composerPackage} | ${true} | ${PACKAGE_TYPE_COMPOSER} + ${pypiPackage} | ${true} | ${PACKAGE_TYPE_PYPI} + ${npmPackage} | ${false} | ${PACKAGE_TYPE_NPM} `( `It is $visible that the component is visible when the package is $packageType`, ({ packageEntity, visible }) => { @@ -74,57 +74,11 @@ describe('Package Additional Metadata', () => { expect(findTitle().exists()).toBe(visible); expect(findMainArea().exists()).toBe(visible); + expect(findComponentIs().exists()).toBe(visible); + + if (visible) { + expect(findComponentIs().props('packageEntity')).toEqual(packageEntity); + } }, ); - - describe('nuget metadata', () => { - beforeEach(() => { - mountComponent({ packageEntity: nugetPackage }); - }); - - it.each` - name | finderFunction | text | link | icon - ${'source'} | ${findNugetSource} | ${'Source project located at projectUrl'} | ${'projectUrl'} | ${'project'} - ${'license'} | ${findNugetLicense} | ${'License information located at licenseUrl'} | ${'licenseUrl'} | ${'license'} - `('$name element', ({ finderFunction, text, link, icon }) => { - const element = finderFunction(); - expect(element.exists()).toBe(true); - expect(element.text()).toBe(text); - expect(element.props('icon')).toBe(icon); - expect(findElementLink(element).attributes('href')).toBe(nugetPackage.metadata[link]); - }); - }); - - describe('conan metadata', () => { - beforeEach(() => { - mountComponent({ packageEntity: conanPackage }); - }); - - it.each` - name | finderFunction | text | icon - ${'recipe'} | ${findConanRecipe} | ${'Recipe: package-8/1.0.0@gitlab-org+gitlab-test/stable'} | ${'information-o'} - `('$name element', ({ finderFunction, text, icon }) => { - const element = finderFunction(); - expect(element.exists()).toBe(true); - expect(element.text()).toBe(text); - expect(element.props('icon')).toBe(icon); - }); - }); - - describe('maven metadata', () => { - beforeEach(() => { - mountComponent(); - }); - - it.each` - name | finderFunction | text | icon - ${'app'} | ${findMavenApp} | ${'App name: appName'} | ${'information-o'} - ${'group'} | ${findMavenGroup} | ${'App group: appGroup'} | ${'information-o'} - `('$name element', ({ finderFunction, text, icon }) => { - const element = finderFunction(); - expect(element.exists()).toBe(true); - expect(element.text()).toBe(text); - expect(element.props('icon')).toBe(icon); - }); - }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/composer_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/composer_spec.js new file mode 100644 index 00000000000..e744680cb9a --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/composer_spec.js @@ -0,0 +1,58 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + packageData, + composerMetadata, +} from 'jest/packages_and_registries/package_registry/mock_data'; +import component from '~/packages_and_registries/package_registry/components/details/metadata/composer.vue'; +import { PACKAGE_TYPE_COMPOSER } from '~/packages_and_registries/package_registry/constants'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; + +const composerPackage = { packageType: PACKAGE_TYPE_COMPOSER, metadata: composerMetadata() }; + +describe('Composer Metadata', () => { + let wrapper; + + const mountComponent = () => { + wrapper = shallowMountExtended(component, { + propsData: { packageEntity: packageData(composerPackage) }, + stubs: { + DetailsRow, + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findComposerTargetSha = () => wrapper.findByTestId('composer-target-sha'); + const findComposerTargetShaCopyButton = () => wrapper.findComponent(ClipboardButton); + const findComposerJson = () => wrapper.findByTestId('composer-json'); + + beforeEach(() => { + mountComponent(); + }); + + it.each` + name | finderFunction | text | icon + ${'target-sha'} | ${findComposerTargetSha} | ${'Target SHA: b83d6e391c22777fca1ed3012fce84f633d7fed0'} | ${'information-o'} + ${'composer-json'} | ${findComposerJson} | ${'Composer.json with license: MIT and version: 1.0.0'} | ${'information-o'} + `('$name element', ({ finderFunction, text, icon }) => { + const element = finderFunction(); + expect(element.exists()).toBe(true); + expect(element.text()).toBe(text); + expect(element.props('icon')).toBe(icon); + }); + + it('target-sha has a copy button', () => { + expect(findComposerTargetShaCopyButton().exists()).toBe(true); + expect(findComposerTargetShaCopyButton().props()).toMatchObject({ + text: 'b83d6e391c22777fca1ed3012fce84f633d7fed0', + title: 'Copy target SHA', + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/conan_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/conan_spec.js new file mode 100644 index 00000000000..46593047f1f --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/conan_spec.js @@ -0,0 +1,48 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + conanMetadata, + packageData, +} from 'jest/packages_and_registries/package_registry/mock_data'; +import component from '~/packages_and_registries/package_registry/components/details/metadata/conan.vue'; +import { PACKAGE_TYPE_CONAN } from '~/packages_and_registries/package_registry/constants'; +import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; + +const conanPackage = { packageType: PACKAGE_TYPE_CONAN, metadata: conanMetadata() }; + +describe('Conan Metadata', () => { + let wrapper; + + const mountComponent = () => { + wrapper = shallowMountExtended(component, { + propsData: { + packageEntity: packageData(conanPackage), + }, + stubs: { + DetailsRow, + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findConanRecipe = () => wrapper.findByTestId('conan-recipe'); + + beforeEach(() => { + mountComponent(); + }); + + it.each` + name | finderFunction | text | icon + ${'recipe'} | ${findConanRecipe} | ${'Recipe: package-8/1.0.0@gitlab-org+gitlab-test/stable'} | ${'information-o'} + `('$name element', ({ finderFunction, text, icon }) => { + const element = finderFunction(); + expect(element.exists()).toBe(true); + expect(element.text()).toBe(text); + expect(element.props('icon')).toBe(icon); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/maven_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/maven_spec.js new file mode 100644 index 00000000000..bc54cf1cb98 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/maven_spec.js @@ -0,0 +1,52 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + mavenMetadata, + packageData, +} from 'jest/packages_and_registries/package_registry/mock_data'; +import component from '~/packages_and_registries/package_registry/components/details/metadata/maven.vue'; +import { PACKAGE_TYPE_MAVEN } from '~/packages_and_registries/package_registry/constants'; +import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; + +const mavenPackage = { packageType: PACKAGE_TYPE_MAVEN, metadata: mavenMetadata() }; + +describe('Maven Metadata', () => { + let wrapper; + + const mountComponent = () => { + wrapper = shallowMountExtended(component, { + propsData: { + packageEntity: { + ...packageData(mavenPackage), + }, + }, + stubs: { + DetailsRow, + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findMavenApp = () => wrapper.findByTestId('maven-app'); + const findMavenGroup = () => wrapper.findByTestId('maven-group'); + + beforeEach(() => { + mountComponent(); + }); + + it.each` + name | finderFunction | text | icon + ${'app'} | ${findMavenApp} | ${'App name: appName'} | ${'information-o'} + ${'group'} | ${findMavenGroup} | ${'App group: appGroup'} | ${'information-o'} + `('$name element', ({ finderFunction, text, icon }) => { + const element = finderFunction(); + expect(element.exists()).toBe(true); + expect(element.text()).toBe(text); + expect(element.props('icon')).toBe(icon); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/nuget_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/nuget_spec.js new file mode 100644 index 00000000000..279900edff2 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/nuget_spec.js @@ -0,0 +1,55 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + nugetMetadata, + packageData, +} from 'jest/packages_and_registries/package_registry/mock_data'; +import component from '~/packages_and_registries/package_registry/components/details/metadata/nuget.vue'; +import { PACKAGE_TYPE_NUGET } from '~/packages_and_registries/package_registry/constants'; + +import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; + +const nugetPackage = { packageType: PACKAGE_TYPE_NUGET, metadata: nugetMetadata() }; + +describe('Nuget Metadata', () => { + let wrapper; + + const mountComponent = () => { + wrapper = shallowMountExtended(component, { + propsData: { + packageEntity: { + ...packageData(nugetPackage), + }, + }, + stubs: { + DetailsRow, + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findNugetSource = () => wrapper.findByTestId('nuget-source'); + const findNugetLicense = () => wrapper.findByTestId('nuget-license'); + const findElementLink = (container) => container.findComponent(GlLink); + + beforeEach(() => { + mountComponent({ packageEntity: nugetPackage }); + }); + + it.each` + name | finderFunction | text | link | icon + ${'source'} | ${findNugetSource} | ${'Source project located at projectUrl'} | ${'projectUrl'} | ${'project'} + ${'license'} | ${findNugetLicense} | ${'License information located at licenseUrl'} | ${'licenseUrl'} | ${'license'} + `('$name element', ({ finderFunction, text, link, icon }) => { + const element = finderFunction(); + expect(element.exists()).toBe(true); + expect(element.text()).toBe(text); + expect(element.props('icon')).toBe(icon); + expect(findElementLink(element).attributes('href')).toBe(nugetPackage.metadata[link]); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js new file mode 100644 index 00000000000..c4481c3f20b --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js @@ -0,0 +1,48 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { packageData, pypiMetadata } from 'jest/packages_and_registries/package_registry/mock_data'; +import component from '~/packages_and_registries/package_registry/components/details/metadata/pypi.vue'; +import { PACKAGE_TYPE_PYPI } from '~/packages_and_registries/package_registry/constants'; + +import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; + +const pypiPackage = { packageType: PACKAGE_TYPE_PYPI, metadata: pypiMetadata() }; + +describe('Package Additional Metadata', () => { + let wrapper; + + const mountComponent = () => { + wrapper = shallowMountExtended(component, { + propsData: { + packageEntity: { + ...packageData(pypiPackage), + }, + }, + stubs: { + DetailsRow, + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findPypiRequiredPython = () => wrapper.findByTestId('pypi-required-python'); + + beforeEach(() => { + mountComponent(); + }); + + it.each` + name | finderFunction | text | icon + ${'pypi-required-python'} | ${findPypiRequiredPython} | ${'Required Python: 1.0.0'} | ${'information-o'} + `('$name element', ({ finderFunction, text, icon }) => { + const element = finderFunction(); + expect(element.exists()).toBe(true); + expect(element.text()).toBe(text); + expect(element.props('icon')).toBe(icon); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js index 327f6d81905..d59c3184e4e 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js @@ -1,5 +1,6 @@ import { GlIcon, GlSprintf } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import PackageTags from '~/packages/shared/components/package_tags.vue'; import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue'; @@ -30,6 +31,9 @@ describe('PackageTitle', () => { TitleArea, GlSprintf, }, + directives: { + GlResizeObserver: createMockDirective(), + }, }); return wrapper.vm.$nextTick(); } @@ -51,7 +55,7 @@ describe('PackageTitle', () => { describe('renders', () => { it('without tags', async () => { - await createComponent(); + await createComponent({ ...packageData(), packageFiles: { nodes: packageFiles() } }); expect(wrapper.element).toMatchSnapshot(); }); @@ -64,12 +68,26 @@ describe('PackageTitle', () => { it('with tags on mobile', async () => { jest.spyOn(GlBreakpointInstance, 'isDesktop').mockReturnValue(false); + await createComponent(); await wrapper.vm.$nextTick(); expect(findPackageBadges()).toHaveLength(packageTags().length); }); + + it('when the page is resized', async () => { + await createComponent(); + + expect(findPackageBadges()).toHaveLength(0); + + jest.spyOn(GlBreakpointInstance, 'isDesktop').mockReturnValue(false); + const { value } = getBinding(wrapper.element, 'gl-resize-observer'); + value(); + + await wrapper.vm.$nextTick(); + expect(findPackageBadges()).toHaveLength(packageTags().length); + }); }); describe('package title', () => { diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/packages_list_app_spec.js.snap new file mode 100644 index 00000000000..dbebdeeb452 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/packages_list_app_spec.js.snap @@ -0,0 +1,68 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`packages_list_app renders 1`] = ` +<div> + <div + help-url="foo" + /> + + <div /> + + <div> + <section + class="row empty-state text-center" + > + <div + class="col-12" + > + <div + class="svg-250 svg-content" + > + <img + alt="" + class="gl-max-w-full" + role="img" + src="helpSvg" + /> + </div> + </div> + + <div + class="col-12" + > + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" + > + <h1 + class="h4" + > + There are no packages yet + </h1> + + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> + + <div + class="gl-display-flex gl-flex-wrap gl-justify-content-center" + > + <!----> + + <!----> + </div> + </div> + </div> + </section> + </div> +</div> +`; diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_app_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_app_spec.js new file mode 100644 index 00000000000..6c871a34d50 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_app_spec.js @@ -0,0 +1,273 @@ +import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import createFlash from '~/flash'; +import * as commonUtils from '~/lib/utils/common_utils'; +import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants'; +import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; +import PackageListApp from '~/packages_and_registries/package_registry/components/list/packages_list_app.vue'; +import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; +import * as packageUtils from '~/packages_and_registries/shared/utils'; + +jest.mock('~/lib/utils/common_utils'); +jest.mock('~/flash'); + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('packages_list_app', () => { + let wrapper; + let store; + + const PackageList = { + name: 'package-list', + template: '<div><slot name="empty-state"></slot></div>', + }; + const GlLoadingIcon = { name: 'gl-loading-icon', template: '<div>loading</div>' }; + + // we need to manually stub dynamic imported components because shallowMount is not able to stub them automatically. See: https://github.com/vuejs/vue-test-utils/issues/1279 + const PackageSearch = { name: 'PackageSearch', template: '<div></div>' }; + const PackageTitle = { name: 'PackageTitle', template: '<div></div>' }; + const InfrastructureTitle = { name: 'InfrastructureTitle', template: '<div></div>' }; + const InfrastructureSearch = { name: 'InfrastructureSearch', template: '<div></div>' }; + + const emptyListHelpUrl = 'helpUrl'; + const findEmptyState = () => wrapper.find(GlEmptyState); + const findListComponent = () => wrapper.find(PackageList); + const findPackageSearch = () => wrapper.find(PackageSearch); + const findPackageTitle = () => wrapper.find(PackageTitle); + const findInfrastructureTitle = () => wrapper.find(InfrastructureTitle); + const findInfrastructureSearch = () => wrapper.find(InfrastructureSearch); + + const createStore = (filter = []) => { + store = new Vuex.Store({ + state: { + isLoading: false, + config: { + resourceId: 'project_id', + emptyListIllustration: 'helpSvg', + emptyListHelpUrl, + packageHelpUrl: 'foo', + }, + filter, + }, + }); + store.dispatch = jest.fn(); + }; + + const mountComponent = (provide) => { + wrapper = shallowMount(PackageListApp, { + localVue, + store, + stubs: { + GlEmptyState, + GlLoadingIcon, + PackageList, + GlSprintf, + GlLink, + PackageSearch, + PackageTitle, + InfrastructureTitle, + InfrastructureSearch, + }, + provide, + }); + }; + + beforeEach(() => { + createStore(); + jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue({}); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders', () => { + mountComponent(); + expect(wrapper.element).toMatchSnapshot(); + }); + + it('call requestPackagesList on page:changed', () => { + mountComponent(); + store.dispatch.mockClear(); + + const list = findListComponent(); + list.vm.$emit('page:changed', 1); + expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList', { page: 1 }); + }); + + it('call requestDeletePackage on package:delete', () => { + mountComponent(); + + const list = findListComponent(); + list.vm.$emit('package:delete', 'foo'); + expect(store.dispatch).toHaveBeenCalledWith('requestDeletePackage', 'foo'); + }); + + it('does call requestPackagesList only one time on render', () => { + mountComponent(); + + expect(store.dispatch).toHaveBeenCalledTimes(3); + expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', expect.any(Object)); + expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', expect.any(Array)); + expect(store.dispatch).toHaveBeenNthCalledWith(3, 'requestPackagesList'); + }); + + describe('url query string handling', () => { + const defaultQueryParamsMock = { + search: [1, 2], + type: 'npm', + sort: 'asc', + orderBy: 'created', + }; + + it('calls setSorting with the query string based sorting', () => { + jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue(defaultQueryParamsMock); + + mountComponent(); + + expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', { + orderBy: defaultQueryParamsMock.orderBy, + sort: defaultQueryParamsMock.sort, + }); + }); + + it('calls setFilter with the query string based filters', () => { + jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue(defaultQueryParamsMock); + + mountComponent(); + + expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', [ + { type: 'type', value: { data: defaultQueryParamsMock.type } }, + { type: FILTERED_SEARCH_TERM, value: { data: defaultQueryParamsMock.search[0] } }, + { type: FILTERED_SEARCH_TERM, value: { data: defaultQueryParamsMock.search[1] } }, + ]); + }); + + it('calls setSorting and setFilters with the results of extractFilterAndSorting', () => { + jest + .spyOn(packageUtils, 'extractFilterAndSorting') + .mockReturnValue({ filters: ['foo'], sorting: { sort: 'desc' } }); + + mountComponent(); + + expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', { sort: 'desc' }); + expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', ['foo']); + }); + }); + + describe('empty state', () => { + it('generate the correct empty list link', () => { + mountComponent(); + + const link = findListComponent().find(GlLink); + + expect(link.attributes('href')).toBe(emptyListHelpUrl); + expect(link.text()).toBe('publish and share your packages'); + }); + + it('includes the right content on the default tab', () => { + mountComponent(); + + const heading = findEmptyState().find('h1'); + + expect(heading.text()).toBe('There are no packages yet'); + }); + }); + + describe('filter without results', () => { + beforeEach(() => { + createStore([{ type: 'something' }]); + mountComponent(); + }); + + it('should show specific empty message', () => { + expect(findEmptyState().text()).toContain('Sorry, your filter produced no results'); + expect(findEmptyState().text()).toContain( + 'To widen your search, change or remove the filters above', + ); + }); + }); + + describe('Package Search', () => { + it('exists', () => { + mountComponent(); + + expect(findPackageSearch().exists()).toBe(true); + }); + + it('on update fetches data from the store', () => { + mountComponent(); + store.dispatch.mockClear(); + + findPackageSearch().vm.$emit('update'); + + expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList'); + }); + }); + + describe('Infrastructure config', () => { + it('defaults to package registry components', () => { + mountComponent(); + + expect(findPackageSearch().exists()).toBe(true); + expect(findPackageTitle().exists()).toBe(true); + + expect(findInfrastructureTitle().exists()).toBe(false); + expect(findInfrastructureSearch().exists()).toBe(false); + }); + + it('mount different component based on the provided values', () => { + mountComponent({ + titleComponent: 'InfrastructureTitle', + searchComponent: 'InfrastructureSearch', + }); + + expect(findPackageSearch().exists()).toBe(false); + expect(findPackageTitle().exists()).toBe(false); + + expect(findInfrastructureTitle().exists()).toBe(true); + expect(findInfrastructureSearch().exists()).toBe(true); + }); + }); + + describe('delete alert handling', () => { + const originalLocation = window.location.href; + const search = `?${SHOW_DELETE_SUCCESS_ALERT}=true`; + + beforeEach(() => { + createStore(); + jest.spyOn(commonUtils, 'historyReplaceState').mockImplementation(() => {}); + setWindowLocation(search); + }); + + afterEach(() => { + setWindowLocation(originalLocation); + }); + + it(`creates a flash if the query string contains ${SHOW_DELETE_SUCCESS_ALERT}`, () => { + mountComponent(); + + expect(createFlash).toHaveBeenCalledWith({ + message: DELETE_PACKAGE_SUCCESS_MESSAGE, + type: 'notice', + }); + }); + + it('calls historyReplaceState with a clean url', () => { + mountComponent(); + + expect(commonUtils.historyReplaceState).toHaveBeenCalledWith(originalLocation); + }); + + it(`does nothing if the query string does not contain ${SHOW_DELETE_SUCCESS_ALERT}`, () => { + setWindowLocation('?'); + mountComponent(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(commonUtils.historyReplaceState).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js new file mode 100644 index 00000000000..b624e66482d --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js @@ -0,0 +1,217 @@ +import { GlTable, GlPagination, GlModal } from '@gitlab/ui'; +import { mount, createLocalVue } from '@vue/test-utils'; +import { last } from 'lodash'; +import Vuex from 'vuex'; +import stubChildren from 'helpers/stub_children'; +import { packageList } from 'jest/packages/mock_data'; +import PackagesListRow from '~/packages/shared/components/package_list_row.vue'; +import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; +import { TrackingActions } from '~/packages/shared/constants'; +import * as SharedUtils from '~/packages/shared/utils'; +import PackagesList from '~/packages_and_registries/package_registry/components/list/packages_list.vue'; +import Tracking from '~/tracking'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('packages_list', () => { + let wrapper; + let store; + + const EmptySlotStub = { name: 'empty-slot-stub', template: '<div>bar</div>' }; + + const findPackagesListLoader = () => wrapper.find(PackagesListLoader); + const findPackageListPagination = () => wrapper.find(GlPagination); + const findPackageListDeleteModal = () => wrapper.find(GlModal); + const findEmptySlot = () => wrapper.find(EmptySlotStub); + const findPackagesListRow = () => wrapper.find(PackagesListRow); + + const createStore = (isGroupPage, packages, isLoading) => { + const state = { + isLoading, + packages, + pagination: { + perPage: 1, + total: 1, + page: 1, + }, + config: { + isGroupPage, + }, + sorting: { + orderBy: 'version', + sort: 'desc', + }, + }; + store = new Vuex.Store({ + state, + getters: { + getList: () => packages, + }, + }); + store.dispatch = jest.fn(); + }; + + const mountComponent = ({ + isGroupPage = false, + packages = packageList, + isLoading = false, + ...options + } = {}) => { + createStore(isGroupPage, packages, isLoading); + + wrapper = mount(PackagesList, { + localVue, + store, + stubs: { + ...stubChildren(PackagesList), + GlTable, + GlModal, + }, + ...options, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when is loading', () => { + beforeEach(() => { + mountComponent({ + packages: [], + isLoading: true, + }); + }); + + it('shows skeleton loader when loading', () => { + expect(findPackagesListLoader().exists()).toBe(true); + }); + }); + + describe('when is not loading', () => { + beforeEach(() => { + mountComponent(); + }); + + it('does not show skeleton loader when not loading', () => { + expect(findPackagesListLoader().exists()).toBe(false); + }); + }); + + describe('layout', () => { + beforeEach(() => { + mountComponent(); + }); + + it('contains a pagination component', () => { + const sorting = findPackageListPagination(); + expect(sorting.exists()).toBe(true); + }); + + it('contains a modal component', () => { + const sorting = findPackageListDeleteModal(); + expect(sorting.exists()).toBe(true); + }); + }); + + describe('when the user can destroy the package', () => { + beforeEach(() => { + mountComponent(); + }); + + it('setItemToBeDeleted sets itemToBeDeleted and open the modal', () => { + const mockModalShow = jest.spyOn(wrapper.vm.$refs.packageListDeleteModal, 'show'); + const item = last(wrapper.vm.list); + + findPackagesListRow().vm.$emit('packageToDelete', item); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.itemToBeDeleted).toEqual(item); + expect(mockModalShow).toHaveBeenCalled(); + }); + }); + + it('deleteItemConfirmation resets itemToBeDeleted', () => { + wrapper.setData({ itemToBeDeleted: 1 }); + wrapper.vm.deleteItemConfirmation(); + expect(wrapper.vm.itemToBeDeleted).toEqual(null); + }); + + it('deleteItemConfirmation emit package:delete', () => { + const itemToBeDeleted = { id: 2 }; + wrapper.setData({ itemToBeDeleted }); + wrapper.vm.deleteItemConfirmation(); + return wrapper.vm.$nextTick(() => { + expect(wrapper.emitted('package:delete')[0]).toEqual([itemToBeDeleted]); + }); + }); + + it('deleteItemCanceled resets itemToBeDeleted', () => { + wrapper.setData({ itemToBeDeleted: 1 }); + wrapper.vm.deleteItemCanceled(); + expect(wrapper.vm.itemToBeDeleted).toEqual(null); + }); + }); + + describe('when the list is empty', () => { + beforeEach(() => { + mountComponent({ + packages: [], + slots: { + 'empty-state': EmptySlotStub, + }, + }); + }); + + it('show the empty slot', () => { + const emptySlot = findEmptySlot(); + expect(emptySlot.exists()).toBe(true); + }); + }); + + describe('pagination component', () => { + let pagination; + let modelEvent; + + beforeEach(() => { + mountComponent(); + pagination = findPackageListPagination(); + // retrieve the event used by v-model, a more sturdy approach than hardcoding it + modelEvent = pagination.vm.$options.model.event; + }); + + it('emits page:changed events when the page changes', () => { + pagination.vm.$emit(modelEvent, 2); + expect(wrapper.emitted('page:changed')).toEqual([[2]]); + }); + }); + + describe('tracking', () => { + let eventSpy; + let utilSpy; + const category = 'foo'; + + beforeEach(() => { + mountComponent(); + eventSpy = jest.spyOn(Tracking, 'event'); + utilSpy = jest.spyOn(SharedUtils, 'packageTypeToTrackCategory').mockReturnValue(category); + wrapper.setData({ itemToBeDeleted: { package_type: 'conan' } }); + }); + + it('tracking category calls packageTypeToTrackCategory', () => { + expect(wrapper.vm.tracking.category).toBe(category); + expect(utilSpy).toHaveBeenCalledWith('conan'); + }); + + it('deleteItemConfirmation calls event', () => { + wrapper.vm.deleteItemConfirmation(); + expect(eventSpy).toHaveBeenCalledWith( + category, + TrackingActions.DELETE_PACKAGE, + expect.any(Object), + ); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js new file mode 100644 index 00000000000..42bc9fa3a9e --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js @@ -0,0 +1,128 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { sortableFields } from '~/packages/list/utils'; +import component from '~/packages_and_registries/package_registry/components/list/package_search.vue'; +import PackageTypeToken from '~/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue'; +import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; +import UrlSync from '~/vue_shared/components/url_sync.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Package Search', () => { + let wrapper; + let store; + + const findRegistrySearch = () => wrapper.findComponent(RegistrySearch); + const findUrlSync = () => wrapper.findComponent(UrlSync); + + const createStore = (isGroupPage) => { + const state = { + config: { + isGroupPage, + }, + sorting: { + orderBy: 'version', + sort: 'desc', + }, + filter: [], + }; + store = new Vuex.Store({ + state, + }); + store.dispatch = jest.fn(); + }; + + const mountComponent = (isGroupPage = false) => { + createStore(isGroupPage); + + wrapper = shallowMount(component, { + localVue, + store, + stubs: { + UrlSync, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('has a registry search component', () => { + mountComponent(); + + expect(findRegistrySearch().exists()).toBe(true); + expect(findRegistrySearch().props()).toMatchObject({ + filter: store.state.filter, + sorting: store.state.sorting, + tokens: expect.arrayContaining([ + expect.objectContaining({ token: PackageTypeToken, type: 'type', icon: 'package' }), + ]), + sortableFields: sortableFields(), + }); + }); + + it.each` + isGroupPage | page + ${false} | ${'project'} + ${true} | ${'group'} + `('in a $page page binds the right props', ({ isGroupPage }) => { + mountComponent(isGroupPage); + + expect(findRegistrySearch().props()).toMatchObject({ + filter: store.state.filter, + sorting: store.state.sorting, + tokens: expect.arrayContaining([ + expect.objectContaining({ token: PackageTypeToken, type: 'type', icon: 'package' }), + ]), + sortableFields: sortableFields(isGroupPage), + }); + }); + + it('on sorting:changed emits update event and calls vuex setSorting', () => { + const payload = { sort: 'foo' }; + + mountComponent(); + + findRegistrySearch().vm.$emit('sorting:changed', payload); + + expect(store.dispatch).toHaveBeenCalledWith('setSorting', payload); + expect(wrapper.emitted('update')).toEqual([[]]); + }); + + it('on filter:changed calls vuex setFilter', () => { + const payload = ['foo']; + + mountComponent(); + + findRegistrySearch().vm.$emit('filter:changed', payload); + + expect(store.dispatch).toHaveBeenCalledWith('setFilter', payload); + }); + + it('on filter:submit emits update event', () => { + mountComponent(); + + findRegistrySearch().vm.$emit('filter:submit'); + + expect(wrapper.emitted('update')).toEqual([[]]); + }); + + it('has a UrlSync component', () => { + mountComponent(); + + expect(findUrlSync().exists()).toBe(true); + }); + + it('on query:changed calls updateQuery from UrlSync', () => { + jest.spyOn(UrlSync.methods, 'updateQuery').mockImplementation(() => {}); + + mountComponent(); + + findRegistrySearch().vm.$emit('query:changed'); + + expect(UrlSync.methods.updateQuery).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js new file mode 100644 index 00000000000..3fa96ce1d29 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js @@ -0,0 +1,71 @@ +import { shallowMount } from '@vue/test-utils'; +import { LIST_INTRO_TEXT, LIST_TITLE_TEXT } from '~/packages/list/constants'; +import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue'; +import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; + +describe('PackageTitle', () => { + let wrapper; + let store; + + const findTitleArea = () => wrapper.find(TitleArea); + const findMetadataItem = () => wrapper.find(MetadataItem); + + const mountComponent = (propsData = { helpUrl: 'foo' }) => { + wrapper = shallowMount(PackageTitle, { + store, + propsData, + stubs: { + TitleArea, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('title area', () => { + it('exists', () => { + mountComponent(); + + expect(findTitleArea().exists()).toBe(true); + }); + + it('has the correct props', () => { + mountComponent(); + + expect(findTitleArea().props()).toMatchObject({ + title: LIST_TITLE_TEXT, + infoMessages: [{ text: LIST_INTRO_TEXT, link: 'foo' }], + }); + }); + }); + + describe.each` + count | exist | text + ${null} | ${false} | ${''} + ${undefined} | ${false} | ${''} + ${0} | ${true} | ${'0 Packages'} + ${1} | ${true} | ${'1 Package'} + ${2} | ${true} | ${'2 Packages'} + `('when count is $count metadata item', ({ count, exist, text }) => { + beforeEach(() => { + mountComponent({ count, helpUrl: 'foo' }); + }); + + it(`is ${exist} that it exists`, () => { + expect(findMetadataItem().exists()).toBe(exist); + }); + + if (exist) { + it('has the correct props', () => { + expect(findMetadataItem().props()).toMatchObject({ + icon: 'package', + text, + }); + }); + } + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js new file mode 100644 index 00000000000..b0cbe34f0b9 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js @@ -0,0 +1,48 @@ +import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import component from '~/packages/list/components/tokens/package_type_token.vue'; +import { PACKAGE_TYPES } from '~/packages/list/constants'; + +describe('packages_filter', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken); + const findFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion); + + const mountComponent = ({ attrs, listeners } = {}) => { + wrapper = shallowMount(component, { + attrs, + listeners, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('it binds all of his attrs to filtered search token', () => { + mountComponent({ attrs: { foo: 'bar' } }); + + expect(findFilteredSearchToken().attributes('foo')).toBe('bar'); + }); + + it('it binds all of his events to filtered search token', () => { + const clickListener = jest.fn(); + mountComponent({ listeners: { click: clickListener } }); + + findFilteredSearchToken().vm.$emit('click'); + + expect(clickListener).toHaveBeenCalled(); + }); + + it.each(PACKAGE_TYPES.map((p, index) => [p, index]))( + 'displays a suggestion for %p', + (packageType, index) => { + mountComponent(); + const item = findFilteredSearchSuggestions().at(index); + expect(item.text()).toBe(packageType.title); + expect(item.props('value')).toBe(packageType.type); + }, + ); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js index 98ff29ef728..9438a2d2d72 100644 --- a/spec/frontend/packages_and_registries/package_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -133,7 +133,7 @@ export const composerMetadata = () => ({ }, }); -export const pypyMetadata = () => ({ +export const pypiMetadata = () => ({ requiredPython: '1.0.0', }); @@ -157,7 +157,7 @@ export const packageDetailsQuery = (extendPackage) => ({ metadata: { ...conanMetadata(), ...composerMetadata(), - ...pypyMetadata(), + ...pypiMetadata(), ...mavenMetadata(), ...nugetMetadata(), }, diff --git a/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js b/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js index 63c1260560b..f84800d8266 100644 --- a/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js +++ b/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js @@ -65,7 +65,7 @@ describe('CustomizeHomepageBanner', () => { await wrapper.vm.$nextTick(); const button = wrapper.find(`[href='${wrapper.vm.preferencesBehaviorPath}']`); - expect(button.attributes('data-track-event')).toEqual(preferencesTrackingEvent); + expect(button.attributes('data-track-action')).toEqual(preferencesTrackingEvent); expect(button.attributes('data-track-label')).toEqual(provide.trackLabel); }); diff --git a/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap b/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap index 4ba9120d196..417567c9f4c 100644 --- a/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap +++ b/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap @@ -11,8 +11,12 @@ exports[`Code Coverage when fetching data is successful matches the snapshot 1`] <gl-dropdown-stub category="primary" + clearalltext="Clear all" headertext="" hideheaderborder="true" + highlighteditemstitle="Selected" + highlighteditemstitleclass="gl-px-5" + showhighlighteditemstitle="true" size="medium" text="rspec" variant="default" diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap deleted file mode 100644 index 091edc7505c..00000000000 --- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap +++ /dev/null @@ -1,604 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Learn GitLab Design B renders correctly 1`] = ` -<div> - <div - class="row" - > - <div - class="gl-mb-7 col-md-8 col-lg-7" - > - <h1 - class="gl-font-size-h1" - > - Learn GitLab - </h1> - - <p - class="gl-text-gray-700 gl-mb-0" - > - Ready to get started with GitLab? Follow these steps to set up your workspace, plan and commit changes, and deploy your project. - </p> - </div> - </div> - - <div - class="gl-mb-3" - > - <p - class="gl-text-gray-500 gl-mb-2" - data-testid="completion-percentage" - > - 22% completed - </p> - - <div - class="progress" - max="9" - value="2" - > - <div - aria-valuemax="9" - aria-valuemin="0" - aria-valuenow="2" - class="progress-bar" - role="progressbar" - style="width: 22.22222222222222%;" - /> - </div> - </div> - - <h2 - class="gl-font-lg gl-mb-3" - > - Set up your workspace - </h2> - - <p - class="gl-text-gray-700 gl-mb-6" - > - Complete these tasks first so you can enjoy GitLab's features to their fullest: - </p> - - <div - class="row row-cols-2 row-cols-md-3 row-cols-lg-4" - > - <div - class="col gl-mb-6" - > - <div - class="gl-card gl-pt-0" - > - <!----> - - <div - class="gl-card-body" - > - <div - class="gl-text-right gl-h-5" - > - <svg - aria-hidden="true" - class="gl-text-green-500 gl-icon s16" - data-testid="completed-icon" - role="img" - > - <use - href="#check-circle-filled" - /> - </svg> - </div> - - <div - class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" - > - <img - alt="Invite your colleagues" - src="http://example.com/images/illustration.svg" - /> - - <h6> - Invite your colleagues - </h6> - - <p - class="gl-font-sm gl-text-gray-700" - > - GitLab works best as a team. Invite your colleague to enjoy all features. - </p> - - <a - class="gl-link" - data-track-action="click_link" - data-track-label="Invite your colleagues" - data-track-property="Growth::Activation::Experiment::LearnGitLabB" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - Invite your colleagues - </a> - </div> - </div> - - <!----> - </div> - </div> - - <div - class="col gl-mb-6" - > - <div - class="gl-card gl-pt-0" - > - <!----> - - <div - class="gl-card-body" - > - <div - class="gl-text-right gl-h-5" - > - <svg - aria-hidden="true" - class="gl-text-green-500 gl-icon s16" - data-testid="completed-icon" - role="img" - > - <use - href="#check-circle-filled" - /> - </svg> - </div> - - <div - class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" - > - <img - alt="Create or import a repository" - src="http://example.com/images/illustration.svg" - /> - - <h6> - Create or import a repository - </h6> - - <p - class="gl-font-sm gl-text-gray-700" - > - Create or import your first repository into your new project. - </p> - - <a - class="gl-link" - data-track-action="click_link" - data-track-label="Create or import a repository" - data-track-property="Growth::Activation::Experiment::LearnGitLabB" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - Create or import a repository - </a> - </div> - </div> - - <!----> - </div> - </div> - - <div - class="col gl-mb-6" - > - <div - class="gl-card gl-pt-0" - > - <!----> - - <div - class="gl-card-body" - > - <div - class="gl-text-right gl-h-5" - > - <!----> - </div> - - <div - class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" - > - <img - alt="Set-up CI/CD" - src="http://example.com/images/illustration.svg" - /> - - <h6> - Set up CI/CD - </h6> - - <p - class="gl-font-sm gl-text-gray-700" - > - Save time by automating your integration and deployment tasks. - </p> - - <a - class="gl-link" - data-track-action="click_link" - data-track-label="Set-up CI/CD" - data-track-property="Growth::Activation::Experiment::LearnGitLabB" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - Set-up CI/CD - </a> - </div> - </div> - - <!----> - </div> - </div> - - <div - class="col gl-mb-6" - > - <div - class="gl-card gl-pt-0" - > - <!----> - - <div - class="gl-card-body" - > - <div - class="gl-text-right gl-h-5" - > - <!----> - </div> - - <div - class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" - > - <img - alt="Try GitLab Ultimate for free" - src="http://example.com/images/illustration.svg" - /> - - <h6> - Start a free Ultimate trial - </h6> - - <p - class="gl-font-sm gl-text-gray-700" - > - Try all GitLab features for 30 days, no credit card required. - </p> - - <a - class="gl-link" - data-track-action="click_link" - data-track-label="Try GitLab Ultimate for free" - data-track-property="Growth::Activation::Experiment::LearnGitLabB" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - Try GitLab Ultimate for free - </a> - </div> - </div> - - <!----> - </div> - </div> - - <div - class="col gl-mb-6" - > - <div - class="gl-card gl-pt-0" - > - <!----> - - <div - class="gl-card-body" - > - <div - class="gl-text-right gl-h-5" - > - <span - class="gl-text-gray-500 gl-font-sm gl-font-style-italic" - data-testid="trial-only" - > - Trial only - </span> - </div> - - <div - class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" - > - <img - alt="Add code owners" - src="http://example.com/images/illustration.svg" - /> - - <h6> - Add code owners - </h6> - - <p - class="gl-font-sm gl-text-gray-700" - > - Prevent unexpected changes to important assets by assigning ownership of files and paths. - </p> - - <a - class="gl-link" - data-track-action="click_link" - data-track-label="Add code owners" - data-track-property="Growth::Activation::Experiment::LearnGitLabB" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - Add code owners - </a> - </div> - </div> - - <!----> - </div> - </div> - - <div - class="col gl-mb-6" - > - <div - class="gl-card gl-pt-0" - > - <!----> - - <div - class="gl-card-body" - > - <div - class="gl-text-right gl-h-5" - > - <span - class="gl-text-gray-500 gl-font-sm gl-font-style-italic" - data-testid="trial-only" - > - Trial only - </span> - </div> - - <div - class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" - > - <img - alt="Enable require merge approvals" - src="http://example.com/images/illustration.svg" - /> - - <h6> - Add merge request approval - </h6> - - <p - class="gl-font-sm gl-text-gray-700" - > - Route code reviews to the right reviewers, every time. - </p> - - <a - class="gl-link" - data-track-action="click_link" - data-track-label="Enable require merge approvals" - data-track-property="Growth::Activation::Experiment::LearnGitLabB" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - Enable require merge approvals - </a> - </div> - </div> - - <!----> - </div> - </div> - </div> - - <h2 - class="gl-font-lg gl-mb-3" - > - Plan and execute - </h2> - - <p - class="gl-text-gray-700 gl-mb-6" - > - Create a workflow for your new workspace, and learn how GitLab features work together: - </p> - - <div - class="row row-cols-2 row-cols-md-3 row-cols-lg-4" - > - <div - class="col gl-mb-6" - > - <div - class="gl-card gl-pt-0" - > - <!----> - - <div - class="gl-card-body" - > - <div - class="gl-text-right gl-h-5" - > - <!----> - </div> - - <div - class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" - > - <img - alt="Create an issue" - src="http://example.com/images/illustration.svg" - /> - - <h6> - Create an issue - </h6> - - <p - class="gl-font-sm gl-text-gray-700" - > - Create/import issues (tickets) to collaborate on ideas and plan work. - </p> - - <a - class="gl-link" - data-track-action="click_link" - data-track-label="Create an issue" - data-track-property="Growth::Activation::Experiment::LearnGitLabB" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - Create an issue - </a> - </div> - </div> - - <!----> - </div> - </div> - - <div - class="col gl-mb-6" - > - <div - class="gl-card gl-pt-0" - > - <!----> - - <div - class="gl-card-body" - > - <div - class="gl-text-right gl-h-5" - > - <!----> - </div> - - <div - class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" - > - <img - alt="Submit a merge request (MR)" - src="http://example.com/images/illustration.svg" - /> - - <h6> - Submit a merge request - </h6> - - <p - class="gl-font-sm gl-text-gray-700" - > - Review and edit proposed changes to source code. - </p> - - <a - class="gl-link" - data-track-action="click_link" - data-track-label="Submit a merge request (MR)" - data-track-property="Growth::Activation::Experiment::LearnGitLabB" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - Submit a merge request (MR) - </a> - </div> - </div> - - <!----> - </div> - </div> - </div> - - <h2 - class="gl-font-lg gl-mb-3" - > - Deploy - </h2> - - <p - class="gl-text-gray-700 gl-mb-6" - > - Use your new GitLab workflow to deploy your application, monitor its health, and keep it secure: - </p> - - <div - class="row row-cols-2 row-cols-lg-4 g-2 g-lg-3" - > - <div - class="col gl-mb-6" - > - <div - class="gl-card gl-pt-0" - > - <!----> - - <div - class="gl-card-body" - > - <div - class="gl-text-right gl-h-5" - > - <!----> - </div> - - <div - class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" - > - <img - alt="Run a Security scan using CI/CD" - src="http://example.com/images/illustration.svg" - /> - - <h6> - Run a Security scan using CI/CD - </h6> - - <p - class="gl-font-sm gl-text-gray-700" - > - Scan your code to uncover vulnerabilities before deploying. - </p> - - <a - class="gl-link" - data-track-action="click_link" - data-track-label="Run a Security scan using CI/CD" - data-track-property="Growth::Activation::Experiment::LearnGitLabB" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - Run a Security scan using CI/CD - </a> - </div> - </div> - - <!----> - </div> - </div> - </div> -</div> -`; diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_a_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap index 59b42de2485..3aa0e99a858 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_a_spec.js.snap +++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Learn GitLab Design A renders correctly 1`] = ` +exports[`Learn GitLab renders correctly 1`] = ` <div> <div class="row" @@ -136,7 +136,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` class="gl-link" data-track-action="click_link" data-track-label="Set up CI/CD" - data-track-property="Growth::Conversion::Experiment::LearnGitLabA" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" href="http://example.com/" rel="noopener noreferrer" target="_blank" @@ -157,7 +157,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` class="gl-link" data-track-action="click_link" data-track-label="Start a free Ultimate trial" - data-track-property="Growth::Conversion::Experiment::LearnGitLabA" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" href="http://example.com/" rel="noopener noreferrer" target="_blank" @@ -178,7 +178,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` class="gl-link" data-track-action="click_link" data-track-label="Add code owners" - data-track-property="Growth::Conversion::Experiment::LearnGitLabA" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" href="http://example.com/" rel="noopener noreferrer" target="_blank" @@ -206,7 +206,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` class="gl-link" data-track-action="click_link" data-track-label="Add merge request approval" - data-track-property="Growth::Conversion::Experiment::LearnGitLabA" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" href="http://example.com/" rel="noopener noreferrer" target="_blank" @@ -270,7 +270,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` class="gl-link" data-track-action="click_link" data-track-label="Create an issue" - data-track-property="Growth::Conversion::Experiment::LearnGitLabA" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" href="http://example.com/" rel="noopener noreferrer" target="_blank" @@ -291,7 +291,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` class="gl-link" data-track-action="click_link" data-track-label="Submit a merge request" - data-track-property="Growth::Conversion::Experiment::LearnGitLabA" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" href="http://example.com/" rel="noopener noreferrer" target="_blank" @@ -348,7 +348,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` class="gl-link" data-track-action="click_link" data-track-label="Run a Security scan using CI/CD" - data-track-property="Growth::Conversion::Experiment::LearnGitLabA" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" href="http://example.com/" rel="noopener noreferrer" target="_blank" diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_b_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_b_spec.js deleted file mode 100644 index 207944bfa1f..00000000000 --- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_b_spec.js +++ /dev/null @@ -1,38 +0,0 @@ -import { GlProgressBar } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import LearnGitlabB from '~/pages/projects/learn_gitlab/components/learn_gitlab_b.vue'; -import { testActions } from './mock_data'; - -describe('Learn GitLab Design B', () => { - let wrapper; - - const createWrapper = () => { - wrapper = mount(LearnGitlabB, { propsData: { actions: testActions } }); - }; - - beforeEach(() => { - createWrapper(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('renders correctly', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - - it('renders the progress percentage', () => { - const text = wrapper.find('[data-testid="completion-percentage"]').text(); - - expect(text).toBe('22% completed'); - }); - - it('renders the progress bar with correct values', () => { - const progressBar = wrapper.findComponent(GlProgressBar); - - expect(progressBar.attributes('value')).toBe('2'); - expect(progressBar.attributes('max')).toBe('9'); - }); -}); diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_a_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js index ac997c1f237..f8099d7e95a 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_a_spec.js +++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js @@ -1,13 +1,13 @@ import { GlProgressBar } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import LearnGitlabA from '~/pages/projects/learn_gitlab/components/learn_gitlab_a.vue'; +import LearnGitlab from '~/pages/projects/learn_gitlab/components/learn_gitlab.vue'; import { testActions, testSections } from './mock_data'; -describe('Learn GitLab Design A', () => { +describe('Learn GitLab', () => { let wrapper; const createWrapper = () => { - wrapper = mount(LearnGitlabA, { propsData: { actions: testActions, sections: testSections } }); + wrapper = mount(LearnGitlab, { propsData: { actions: testActions, sections: testSections } }); }; beforeEach(() => { diff --git a/spec/frontend/pages/projects/new/components/new_project_url_select_spec.js b/spec/frontend/pages/projects/new/components/new_project_url_select_spec.js new file mode 100644 index 00000000000..8a7f9229503 --- /dev/null +++ b/spec/frontend/pages/projects/new/components/new_project_url_select_spec.js @@ -0,0 +1,122 @@ +import { GlButton, GlDropdown, GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui'; +import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import NewProjectUrlSelect from '~/pages/projects/new/components/new_project_url_select.vue'; +import searchQuery from '~/pages/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql'; + +describe('NewProjectUrlSelect component', () => { + let wrapper; + + const data = { + currentUser: { + groups: { + nodes: [ + { + id: 'gid://gitlab/Group/26', + fullPath: 'flightjs', + }, + { + id: 'gid://gitlab/Group/28', + fullPath: 'h5bp', + }, + ], + }, + namespace: { + id: 'gid://gitlab/Namespace/1', + fullPath: 'root', + }, + }, + }; + + const localVue = createLocalVue(); + localVue.use(VueApollo); + + const requestHandlers = [[searchQuery, jest.fn().mockResolvedValue({ data })]]; + const apolloProvider = createMockApollo(requestHandlers); + + const provide = { + namespaceFullPath: 'h5bp', + namespaceId: '28', + rootUrl: 'https://gitlab.com/', + trackLabel: 'blank_project', + }; + + const mountComponent = ({ mountFn = shallowMount } = {}) => + mountFn(NewProjectUrlSelect, { localVue, apolloProvider, provide }); + + const findButtonLabel = () => wrapper.findComponent(GlButton); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findHiddenInput = () => wrapper.find('input'); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the root url as a label', () => { + wrapper = mountComponent(); + + expect(findButtonLabel().text()).toBe(provide.rootUrl); + expect(findButtonLabel().props('label')).toBe(true); + }); + + it('renders a dropdown with the initial namespace full path as the text', () => { + wrapper = mountComponent(); + + expect(findDropdown().props('text')).toBe(provide.namespaceFullPath); + }); + + it('renders a dropdown with the initial namespace id in the hidden input', () => { + wrapper = mountComponent(); + + expect(findHiddenInput().attributes('value')).toBe(provide.namespaceId); + }); + + it('renders expected dropdown items', async () => { + wrapper = mountComponent({ mountFn: mount }); + + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + + const listItems = wrapper.findAll('li'); + + expect(listItems.at(0).findComponent(GlDropdownSectionHeader).text()).toBe('Groups'); + expect(listItems.at(1).text()).toBe(data.currentUser.groups.nodes[0].fullPath); + expect(listItems.at(2).text()).toBe(data.currentUser.groups.nodes[1].fullPath); + expect(listItems.at(3).findComponent(GlDropdownSectionHeader).text()).toBe('Users'); + expect(listItems.at(4).text()).toBe(data.currentUser.namespace.fullPath); + }); + + it('updates hidden input with selected namespace', async () => { + wrapper = mountComponent(); + + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + + wrapper.findComponent(GlDropdownItem).vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + expect(findHiddenInput().attributes()).toMatchObject({ + name: 'project[namespace_id]', + value: getIdFromGraphQLId(data.currentUser.groups.nodes[0].id).toString(), + }); + }); + + it('tracks clicking on the dropdown', () => { + wrapper = mountComponent(); + + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + findDropdown().vm.$emit('show'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'activate_form_input', { + label: provide.trackLabel, + property: 'project_path', + }); + + unmockTracking(); + }); +}); diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js index de0d70a07d7..f3d76ca2c1b 100644 --- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js @@ -42,11 +42,6 @@ describe('Interval Pattern Input Component', () => { wrapper = mount(IntervalPatternInput, { propsData: { ...props }, - provide: { - glFeatures: { - ciDailyLimitForPipelineSchedules: true, - }, - }, data() { return { randomHour: data?.hour || mockHour, diff --git a/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js b/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js index 6aa725fbd7d..601fcfedbe0 100644 --- a/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js +++ b/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js @@ -21,7 +21,7 @@ describe('SigninTabsMemoizer', () => { beforeEach(() => { loadFixtures(fixtureTemplate); - jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(true); + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true); }); it('does nothing if no tab was previously selected', () => { @@ -90,7 +90,7 @@ describe('SigninTabsMemoizer', () => { }); it('should set .isLocalStorageAvailable', () => { - expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + expect(AccessorUtilities.canUseLocalStorage).toHaveBeenCalled(); expect(memo.isLocalStorageAvailable).toBe(true); }); }); diff --git a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js b/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js index 39081e07e52..2f934898ef1 100644 --- a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js +++ b/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js @@ -1,5 +1,6 @@ import { GlFormTextarea, GlFormInput, GlLoadingIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; import { objectToQuery, redirectTo } from '~/lib/utils/url_utility'; import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue'; import CommitSection from '~/pipeline_editor/components/commit/commit_section.vue'; @@ -48,7 +49,10 @@ describe('Pipeline Editor | Commit section', () => { let wrapper; let mockMutate; - const defaultProps = { ciFileContent: mockCiYml }; + const defaultProps = { + ciFileContent: mockCiYml, + commitSha: mockCommitSha, + }; const createComponent = ({ props = {}, options = {}, provide = {} } = {}) => { mockMutate = jest.fn().mockResolvedValue({ @@ -67,7 +71,6 @@ describe('Pipeline Editor | Commit section', () => { provide: { ...mockProvide, ...provide }, data() { return { - commitSha: mockCommitSha, currentBranch: mockDefaultBranch, isNewCiConfigFile: Boolean(options?.isNewCiConfigfile), }; @@ -97,8 +100,7 @@ describe('Pipeline Editor | Commit section', () => { await findCommitForm().find('[data-testid="new-mr-checkbox"]').setChecked(openMergeRequest); } await findCommitForm().find('[type="submit"]').trigger('click'); - // Simulate the write to local cache that occurs after a commit - await wrapper.setData({ commitSha: mockCommitNextSha }); + await waitForPromises(); }; const cancelCommitForm = async () => { @@ -175,6 +177,10 @@ describe('Pipeline Editor | Commit section', () => { expect(wrapper.emitted('commit')[0]).toEqual([{ type: COMMIT_SUCCESS }]); }); + it('emits an event to refetch the commit sha', () => { + expect(wrapper.emitted('updateCommitSha')).toHaveLength(1); + }); + it('shows no saving state', () => { expect(findCommitBtnLoadingIcon().exists()).toBe(false); }); @@ -188,7 +194,6 @@ describe('Pipeline Editor | Commit section', () => { update: expect.any(Function), variables: { ...mockVariables, - lastCommitId: mockCommitNextSha, branch: mockDefaultBranch, }, }); @@ -215,6 +220,10 @@ describe('Pipeline Editor | Commit section', () => { }, }); }); + + it('does not emit an event to refetch the commit sha', () => { + expect(wrapper.emitted('updateCommitSha')).toBeUndefined(); + }); }); describe('when the user commits changes to open a new merge request', () => { diff --git a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js index c6c7f593cc5..85222f2ecbb 100644 --- a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js +++ b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js @@ -42,15 +42,12 @@ describe('Pipeline Editor | Text editor component', () => { defaultBranch: mockDefaultBranch, glFeatures, }, + propsData: { + commitSha: mockCommitSha, + }, attrs: { value: mockCiYml, }, - // Simulate graphQL client query result - data() { - return { - commitSha: mockCommitSha, - }; - }, listeners: { [EDITOR_READY_EVENT]: editorReadyListener, }, diff --git a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js index 85b51d08f88..b5881790b0b 100644 --- a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js +++ b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js @@ -247,15 +247,6 @@ describe('Pipeline editor branch switcher', () => { expect(wrapper.emitted('refetchContent')).toBeUndefined(); }); - - it('emits the updateCommitSha event when selecting a different branch', async () => { - expect(wrapper.emitted('updateCommitSha')).toBeUndefined(); - - const branch = findDropdownItems().at(1); - branch.vm.$emit('click'); - - expect(wrapper.emitted('updateCommitSha')).toHaveLength(1); - }); }); describe('when searching', () => { diff --git a/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js b/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js index 94a0a7d14ee..e24de832d6d 100644 --- a/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js +++ b/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js @@ -4,16 +4,10 @@ import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipelin describe('Pipeline editor file nav', () => { let wrapper; - const mockProvide = { - glFeatures: { - pipelineEditorBranchSwitcher: true, - }, - }; const createComponent = ({ provide = {} } = {}) => { wrapper = shallowMount(PipelineEditorFileNav, { provide: { - ...mockProvide, ...provide, }, }); @@ -34,16 +28,4 @@ describe('Pipeline editor file nav', () => { expect(findBranchSwitcher().exists()).toBe(true); }); }); - - describe('with branch switcher feature flag OFF', () => { - it('does not render the branch switcher', () => { - createComponent({ - provide: { - glFeatures: { pipelineEditorBranchSwitcher: false }, - }, - }); - - expect(findBranchSwitcher().exists()).toBe(false); - }); - }); }); diff --git a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js index a95921359cc..753682d438b 100644 --- a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js +++ b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js @@ -27,13 +27,11 @@ describe('Pipeline Status', () => { wrapper = shallowMount(PipelineStatus, { localVue, apolloProvider: mockApollo, + propsData: { + commitSha: mockCommitSha, + }, provide: mockProvide, stubs: { GlLink, GlSprintf }, - data() { - return { - commitSha: mockCommitSha, - }; - }, }); }; diff --git a/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js b/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js index 76c68e21180..b019bae886c 100644 --- a/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js +++ b/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js @@ -7,7 +7,6 @@ describe('Pipeline editor empty state', () => { let wrapper; const defaultProvide = { glFeatures: { - pipelineEditorBranchSwitcher: true, pipelineEditorEmptyStateAction: false, }, emptyStateIllustrationPath: 'my/svg/path', @@ -82,17 +81,5 @@ describe('Pipeline editor empty state', () => { await findConfirmButton().vm.$emit('click'); expect(wrapper.emitted(expectedEvent)).toHaveLength(1); }); - - describe('with branch switcher feature flag OFF', () => { - it('does not render the file nav', () => { - createComponent({ - provide: { - glFeatures: { pipelineEditorBranchSwitcher: false }, - }, - }); - - expect(findFileNav().exists()).toBe(false); - }); - }); }); }); diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index 4d4a8c21d78..f2104f25324 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -156,30 +156,43 @@ export const mergeUnwrappedCiConfig = (mergedConfig) => { }; }; -export const mockNewCommitShaResults = { +export const mockCommitShaResults = { data: { project: { - pipelines: { - nodes: [ - { - id: 'gid://gitlab/Ci::Pipeline/1', - sha: 'd0d56d363d8a3f67a8ab9fc00207d468f30032ca', - path: `/${mockProjectFullPath}/-/pipelines/488`, - commitPath: `/${mockProjectFullPath}/-/commit/d0d56d363d8a3f67a8ab9fc00207d468f30032ca`, + repository: { + tree: { + lastCommit: { + sha: mockCommitSha, }, - { - id: 'gid://gitlab/Ci::Pipeline/2', - sha: 'fcab2ece40b26f428dfa3aa288b12c3c5bdb06aa', - path: `/${mockProjectFullPath}/-/pipelines/487`, - commitPath: `/${mockProjectFullPath}/-/commit/fcab2ece40b26f428dfa3aa288b12c3c5bdb06aa`, + }, + }, + }, + }, +}; + +export const mockNewCommitShaResults = { + data: { + project: { + repository: { + tree: { + lastCommit: { + sha: 'eeff1122', }, - { - id: 'gid://gitlab/Ci::Pipeline/3', - sha: '6c16b17c7f94a438ae19a96c285bb49e3c632cf4', - path: `/${mockProjectFullPath}/-/pipelines/433`, - commitPath: `/${mockProjectFullPath}/-/commit/6c16b17c7f94a438ae19a96c285bb49e3c632cf4`, + }, + }, + }, + }, +}; + +export const mockEmptyCommitShaResults = { + data: { + project: { + repository: { + tree: { + lastCommit: { + sha: '', }, - ], + }, }, }, }, diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js index 0c5c08d7190..393cad0546b 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -26,9 +26,11 @@ import { mockBlobContentQueryResponseNoCiFile, mockCiYml, mockCommitSha, + mockCommitShaResults, mockDefaultBranch, - mockProjectFullPath, + mockEmptyCommitShaResults, mockNewCommitShaResults, + mockProjectFullPath, } from './mock_data'; const localVue = createLocalVue(); @@ -54,7 +56,6 @@ describe('Pipeline editor app component', () => { let mockBlobContentData; let mockCiConfigData; let mockGetTemplate; - let mockUpdateCommitSha; let mockLatestCommitShaQuery; let mockPipelineQuery; @@ -71,6 +72,11 @@ describe('Pipeline editor app component', () => { SourceEditor: MockSourceEditor, PipelineEditorEmptyState, }, + data() { + return { + commitSha: '', + }; + }, mocks: { $apollo: { queries: { @@ -96,18 +102,7 @@ describe('Pipeline editor app component', () => { [getPipelineQuery, mockPipelineQuery], ]; - const resolvers = { - Query: { - commitSha() { - return mockCommitSha; - }, - }, - Mutation: { - updateCommitSha: mockUpdateCommitSha, - }, - }; - - mockApollo = createMockApollo(handlers, resolvers); + mockApollo = createMockApollo(handlers); const options = { localVue, @@ -137,7 +132,6 @@ describe('Pipeline editor app component', () => { mockBlobContentData = jest.fn(); mockCiConfigData = jest.fn(); mockGetTemplate = jest.fn(); - mockUpdateCommitSha = jest.fn(); mockLatestCommitShaQuery = jest.fn(); mockPipelineQuery = jest.fn(); }); @@ -159,11 +153,16 @@ describe('Pipeline editor app component', () => { beforeEach(() => { mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); mockCiConfigData.mockResolvedValue(mockCiConfigQueryResponse); + mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults); }); describe('when file exists', () => { beforeEach(async () => { await createComponentWithApollo(); + + jest + .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling') + .mockImplementation(jest.fn()); }); it('shows pipeline editor home component', () => { @@ -181,18 +180,32 @@ describe('Pipeline editor app component', () => { sha: mockCommitSha, }); }); + + it('does not poll for the commit sha', () => { + expect(wrapper.vm.$apollo.queries.commitSha.startPolling).toHaveBeenCalledTimes(0); + }); }); describe('when no CI config file exists', () => { - it('shows an empty state and does not show editor home component', async () => { + beforeEach(async () => { mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponseNoCiFile); await createComponentWithApollo(); + jest + .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling') + .mockImplementation(jest.fn()); + }); + + it('shows an empty state and does not show editor home component', async () => { expect(findEmptyState().exists()).toBe(true); expect(findAlert().exists()).toBe(false); expect(findEditorHome().exists()).toBe(false); }); + it('does not poll for the commit sha', () => { + expect(wrapper.vm.$apollo.queries.commitSha.startPolling).toHaveBeenCalledTimes(0); + }); + describe('because of a fetching error', () => { it('shows a unkown error message', async () => { const loadUnknownFailureText = 'The CI configuration was not loaded, please try again.'; @@ -230,6 +243,7 @@ describe('Pipeline editor app component', () => { describe('when landing on the empty state with feature flag on', () => { it('user can click on CTA button and see an empty editor', async () => { mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponseNoCiFile); + mockLatestCommitShaQuery.mockResolvedValue(mockEmptyCommitShaResults); await createComponentWithApollo({ provide: { @@ -254,9 +268,9 @@ describe('Pipeline editor app component', () => { const updateSuccessMessage = 'Your changes have been successfully committed.'; describe('and the commit mutation succeeds', () => { - beforeEach(() => { + beforeEach(async () => { window.scrollTo = jest.fn(); - createComponent(); + await createComponentWithApollo(); findEditorHome().vm.$emit('commit', { type: COMMIT_SUCCESS }); }); @@ -268,7 +282,43 @@ describe('Pipeline editor app component', () => { it('scrolls to the top of the page to bring attention to the confirmation message', () => { expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); }); + + it('polls for commit sha while pipeline data is not yet available for current branch', async () => { + jest + .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling') + .mockImplementation(jest.fn()); + + // simulate a commit to the current branch + findEditorHome().vm.$emit('updateCommitSha'); + await waitForPromises(); + + expect(wrapper.vm.$apollo.queries.commitSha.startPolling).toHaveBeenCalledTimes(1); + }); + + it('stops polling for commit sha when pipeline data is available for newly committed branch', async () => { + jest + .spyOn(wrapper.vm.$apollo.queries.commitSha, 'stopPolling') + .mockImplementation(jest.fn()); + + mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults); + await wrapper.vm.$apollo.queries.commitSha.refetch(); + + expect(wrapper.vm.$apollo.queries.commitSha.stopPolling).toHaveBeenCalledTimes(1); + }); + + it('stops polling for commit sha when pipeline data is available for current branch', async () => { + jest + .spyOn(wrapper.vm.$apollo.queries.commitSha, 'stopPolling') + .mockImplementation(jest.fn()); + + mockLatestCommitShaQuery.mockResolvedValue(mockNewCommitShaResults); + findEditorHome().vm.$emit('updateCommitSha'); + await waitForPromises(); + + expect(wrapper.vm.$apollo.queries.commitSha.stopPolling).toHaveBeenCalledTimes(1); + }); }); + describe('and the commit mutation fails', () => { const commitFailedReasons = ['Commit failed']; @@ -320,6 +370,10 @@ describe('Pipeline editor app component', () => { }); describe('when refetching content', () => { + beforeEach(() => { + mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults); + }); + it('refetches blob content', async () => { await createComponentWithApollo(); jest @@ -352,6 +406,7 @@ describe('Pipeline editor app component', () => { const originalLocation = window.location.href; beforeEach(() => { + mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults); setWindowLocation('?template=Android'); }); @@ -371,45 +426,4 @@ describe('Pipeline editor app component', () => { expect(findTextEditor().exists()).toBe(true); }); }); - - describe('when updating commit sha', () => { - const newCommitSha = mockNewCommitShaResults.data.project.pipelines.nodes[0].sha; - - beforeEach(async () => { - mockUpdateCommitSha.mockResolvedValue(newCommitSha); - mockLatestCommitShaQuery.mockResolvedValue(mockNewCommitShaResults); - await createComponentWithApollo(); - }); - - it('fetches updated commit sha for the new branch', async () => { - expect(mockLatestCommitShaQuery).not.toHaveBeenCalled(); - - wrapper - .findComponent(PipelineEditorHome) - .vm.$emit('updateCommitSha', { newBranch: 'new-branch' }); - await waitForPromises(); - - expect(mockLatestCommitShaQuery).toHaveBeenCalledWith({ - projectPath: mockProjectFullPath, - ref: 'new-branch', - }); - }); - - it('updates commit sha with the newly fetched commit sha', async () => { - expect(mockUpdateCommitSha).not.toHaveBeenCalled(); - - wrapper - .findComponent(PipelineEditorHome) - .vm.$emit('updateCommitSha', { newBranch: 'new-branch' }); - await waitForPromises(); - - expect(mockUpdateCommitSha).toHaveBeenCalled(); - expect(mockUpdateCommitSha).toHaveBeenCalledWith( - expect.any(Object), - { commitSha: mockNewCommitShaResults.data.project.pipelines.nodes[0].sha }, - expect.any(Object), - expect.any(Object), - ); - }); - }); }); diff --git a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js index 2a3f4f56f36..9e2bf1bd367 100644 --- a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js +++ b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js @@ -45,6 +45,7 @@ describe('Pipeline New Form', () => { const findWarningAlertSummary = () => findWarningAlert().find(GlSprintf); const findWarnings = () => wrapper.findAll('[data-testid="run-pipeline-warning"]'); const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findCCAlert = () => wrapper.findComponent(CreditCardValidationRequiredAlert); const getFormPostParams = () => JSON.parse(mock.history.post[0].data); const selectBranch = (branch) => { @@ -387,7 +388,7 @@ describe('Pipeline New Form', () => { }); it('does not show the credit card validation required alert', () => { - expect(wrapper.findComponent(CreditCardValidationRequiredAlert).exists()).toBe(false); + expect(findCCAlert().exists()).toBe(false); }); describe('when the error response is credit card validation required', () => { @@ -408,7 +409,19 @@ describe('Pipeline New Form', () => { it('shows credit card validation required alert', () => { expect(findErrorAlert().exists()).toBe(false); - expect(wrapper.findComponent(CreditCardValidationRequiredAlert).exists()).toBe(true); + expect(findCCAlert().exists()).toBe(true); + }); + + it('clears error and hides the alert on dismiss', async () => { + expect(findCCAlert().exists()).toBe(true); + expect(wrapper.vm.$data.error).toBe(mockCreditCardValidationRequiredError.errors[0]); + + findCCAlert().vm.$emit('dismiss'); + + await wrapper.vm.$nextTick(); + + expect(findCCAlert().exists()).toBe(false); + expect(wrapper.vm.$data.error).toBe(null); }); }); }); diff --git a/spec/frontend/pipelines/__snapshots__/parsing_utils_spec.js.snap b/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap index 60625d301c0..60625d301c0 100644 --- a/spec/frontend/pipelines/__snapshots__/parsing_utils_spec.js.snap +++ b/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap diff --git a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js index e0ba6b2e8da..661c8d99477 100644 --- a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js +++ b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js @@ -33,8 +33,6 @@ describe('Pipelines filtered search', () => { }; beforeEach(() => { - window.gon = { features: { pipelineSourceFilter: true } }; - mock = new MockAdapter(axios); jest.spyOn(Api, 'projectUsers').mockResolvedValue(users); diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js index 1fba3823161..4b2b61c8edd 100644 --- a/spec/frontend/pipelines/graph/graph_component_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_spec.js @@ -1,5 +1,5 @@ import { mount, shallowMount } from '@vue/test-utils'; -import { GRAPHQL, LAYER_VIEW, STAGE_VIEW } from '~/pipelines/components/graph/constants'; +import { LAYER_VIEW, STAGE_VIEW } from '~/pipelines/components/graph/constants'; import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; import JobItem from '~/pipelines/components/graph/job_item.vue'; import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; @@ -54,9 +54,6 @@ describe('graph component', () => { ...data, }; }, - provide: { - dataMethod: GRAPHQL, - }, stubs: { 'links-inner': true, 'linked-pipeline': true, diff --git a/spec/frontend/pipelines/graph/job_item_spec.js b/spec/frontend/pipelines/graph/job_item_spec.js index 4c7ea5edda9..cbc5d11403e 100644 --- a/spec/frontend/pipelines/graph/job_item_spec.js +++ b/spec/frontend/pipelines/graph/job_item_spec.js @@ -14,7 +14,29 @@ describe('pipeline graph job item', () => { }; const triggerActiveClass = 'gl-shadow-x0-y0-b3-s1-blue-500'; - const delayedJobFixture = getJSONFixture('jobs/delayed.json'); + + const delayedJob = { + __typename: 'CiJob', + name: 'delayed job', + scheduledAt: '2015-07-03T10:01:00.000Z', + needs: [], + status: { + __typename: 'DetailedStatus', + icon: 'status_scheduled', + tooltip: 'delayed manual action (%{remainingTime})', + hasDetails: true, + detailsPath: '/root/kinder-pipe/-/jobs/5339', + group: 'scheduled', + action: { + __typename: 'StatusAction', + icon: 'time-out', + title: 'Unschedule', + path: '/frontend-fixtures/builds-project/-/jobs/142/unschedule', + buttonTitle: 'Unschedule job', + }, + }, + }; + const mockJob = { id: 4256, name: 'test', @@ -24,8 +46,8 @@ describe('pipeline graph job item', () => { label: 'passed', tooltip: 'passed', group: 'success', - details_path: '/root/ci-mock/builds/4256', - has_details: true, + detailsPath: '/root/ci-mock/builds/4256', + hasDetails: true, action: { icon: 'retry', title: 'Retry', @@ -42,8 +64,8 @@ describe('pipeline graph job item', () => { text: 'passed', label: 'passed', group: 'success', - details_path: '/root/ci-mock/builds/4257', - has_details: false, + detailsPath: '/root/ci-mock/builds/4257', + hasDetails: false, }, }; @@ -58,7 +80,7 @@ describe('pipeline graph job item', () => { wrapper.vm.$nextTick(() => { const link = wrapper.find('a'); - expect(link.attributes('href')).toBe(mockJob.status.details_path); + expect(link.attributes('href')).toBe(mockJob.status.detailsPath); expect(link.attributes('title')).toBe(`${mockJob.name} - ${mockJob.status.label}`); @@ -145,7 +167,7 @@ describe('pipeline graph job item', () => { describe('for delayed job', () => { it('displays remaining time in tooltip', () => { createWrapper({ - job: delayedJobFixture, + job: delayedJob, }); expect(findJobWithLink().attributes('title')).toBe( diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js index c7d95526a0c..af5cd907dd8 100644 --- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js @@ -4,11 +4,7 @@ import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { UPSTREAM, DOWNSTREAM } from '~/pipelines/components/graph/constants'; import LinkedPipelineComponent from '~/pipelines/components/graph/linked_pipeline.vue'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; -import mockData from './linked_pipelines_mock_data'; - -const mockPipeline = mockData.triggered[0]; -const validTriggeredPipelineId = mockPipeline.project.id; -const invalidTriggeredPipelineId = mockPipeline.project.id + 5; +import mockPipeline from './linked_pipelines_mock_data'; describe('Linked pipeline', () => { let wrapper; @@ -39,10 +35,10 @@ describe('Linked pipeline', () => { describe('rendered output', () => { const props = { pipeline: mockPipeline, - projectId: invalidTriggeredPipelineId, columnTitle: 'Downstream', type: DOWNSTREAM, expanded: false, + isLoading: false, }; beforeEach(() => { @@ -60,7 +56,7 @@ describe('Linked pipeline', () => { }); it('should render the pipeline status icon svg', () => { - expect(wrapper.find('.ci-status-icon-failed svg').exists()).toBe(true); + expect(wrapper.find('.ci-status-icon-success svg').exists()).toBe(true); }); it('should have a ci-status child component', () => { @@ -73,8 +69,8 @@ describe('Linked pipeline', () => { it('should correctly compute the tooltip text', () => { expect(wrapper.vm.tooltipText).toContain(mockPipeline.project.name); - expect(wrapper.vm.tooltipText).toContain(mockPipeline.details.status.label); - expect(wrapper.vm.tooltipText).toContain(mockPipeline.source_job.name); + expect(wrapper.vm.tooltipText).toContain(mockPipeline.status.label); + expect(wrapper.vm.tooltipText).toContain(mockPipeline.sourceJob.name); expect(wrapper.vm.tooltipText).toContain(mockPipeline.id); }); @@ -82,11 +78,7 @@ describe('Linked pipeline', () => { const titleAttr = findLinkedPipeline().attributes('title'); expect(titleAttr).toContain(mockPipeline.project.name); - expect(titleAttr).toContain(mockPipeline.details.status.label); - }); - - it('sets the loading prop to false', () => { - expect(findButton().props('loading')).toBe(false); + expect(titleAttr).toContain(mockPipeline.status.label); }); it('should display multi-project label when pipeline project id is not the same as triggered pipeline project id', () => { @@ -96,18 +88,20 @@ describe('Linked pipeline', () => { describe('parent/child', () => { const downstreamProps = { - pipeline: mockPipeline, - projectId: validTriggeredPipelineId, + pipeline: { + ...mockPipeline, + multiproject: false, + }, columnTitle: 'Downstream', type: DOWNSTREAM, expanded: false, + isLoading: false, }; const upstreamProps = { ...downstreamProps, columnTitle: 'Upstream', type: UPSTREAM, - expanded: false, }; it('parent/child label container should exist', () => { @@ -122,7 +116,7 @@ describe('Linked pipeline', () => { it('should have the name of the trigger job on the card when it is a child pipeline', () => { createWrapper(downstreamProps); - expect(findDownstreamPipelineTitle().text()).toBe(mockPipeline.source_job.name); + expect(findDownstreamPipelineTitle().text()).toBe(mockPipeline.sourceJob.name); }); it('should display parent label when pipeline project id is the same as triggered_by pipeline project id', () => { @@ -132,12 +126,12 @@ describe('Linked pipeline', () => { it('downstream pipeline should contain the correct link', () => { createWrapper(downstreamProps); - expect(findPipelineLink().attributes('href')).toBe(mockData.triggered_by.path); + expect(findPipelineLink().attributes('href')).toBe(downstreamProps.pipeline.path); }); it('upstream pipeline should contain the correct link', () => { createWrapper(upstreamProps); - expect(findPipelineLink().attributes('href')).toBe(mockData.triggered_by.path); + expect(findPipelineLink().attributes('href')).toBe(upstreamProps.pipeline.path); }); it.each` @@ -183,11 +177,11 @@ describe('Linked pipeline', () => { describe('when isLoading is true', () => { const props = { - pipeline: { ...mockPipeline, isLoading: true }, - projectId: invalidTriggeredPipelineId, + pipeline: mockPipeline, columnTitle: 'Downstream', type: DOWNSTREAM, expanded: false, + isLoading: true, }; beforeEach(() => { @@ -202,10 +196,10 @@ describe('Linked pipeline', () => { describe('on click/hover', () => { const props = { pipeline: mockPipeline, - projectId: validTriggeredPipelineId, columnTitle: 'Downstream', type: DOWNSTREAM, expanded: false, + isLoading: false, }; beforeEach(() => { @@ -228,7 +222,7 @@ describe('Linked pipeline', () => { it('should emit downstreamHovered with job name on mouseover', () => { findLinkedPipeline().trigger('mouseover'); - expect(wrapper.emitted().downstreamHovered).toStrictEqual([['trigger_job']]); + expect(wrapper.emitted().downstreamHovered).toStrictEqual([['test_c']]); }); it('should emit downstreamHovered with empty string on mouseleave', () => { @@ -238,7 +232,7 @@ describe('Linked pipeline', () => { it('should emit pipelineExpanded with job name and expanded state on click', () => { findExpandButton().trigger('click'); - expect(wrapper.emitted().pipelineExpandToggle).toStrictEqual([['trigger_job', true]]); + expect(wrapper.emitted().pipelineExpandToggle).toStrictEqual([['test_c', true]]); }); }); }); diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js index 24cc6e76098..2f03b846525 100644 --- a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js @@ -4,7 +4,6 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; import { DOWNSTREAM, - GRAPHQL, UPSTREAM, LAYER_VIEW, STAGE_VIEW, @@ -52,9 +51,6 @@ describe('Linked Pipelines Column', () => { ...defaultProps, ...props, }, - provide: { - dataMethod: GRAPHQL, - }, }); }; diff --git a/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js b/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js index eb05669463b..955b70cbd3b 100644 --- a/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js +++ b/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js @@ -1,3800 +1,22 @@ export default { - id: 23211253, - user: { - id: 3585, - name: 'Achilleas Pipinellis', - username: 'axil', - state: 'active', - avatar_url: 'https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png', - web_url: 'https://gitlab.com/axil', - status_tooltip_html: - '\u003cspan class="user-status-emoji has-tooltip" title="I like pizza" data-html="true" data-placement="top"\u003e\u003cgl-emoji title="slice of pizza" data-name="pizza" data-unicode-version="6.0"\u003e🍕\u003c/gl-emoji\u003e\u003c/span\u003e', - path: '/axil', + __typename: 'Pipeline', + id: 195, + iid: '5', + path: '/root/elemenohpee/-/pipelines/195', + status: { + __typename: 'DetailedStatus', + group: 'success', + label: 'passed', + icon: 'status_success', }, - active: false, - coverage: null, - source: 'push', - source_job: { - name: 'trigger_job', + sourceJob: { + __typename: 'CiJob', + name: 'test_c', }, - created_at: '2018-06-05T11:31:30.452Z', - updated_at: '2018-10-31T16:35:31.305Z', - path: '/gitlab-org/gitlab-runner/pipelines/23211253', - flags: { - latest: false, - stuck: false, - auto_devops: false, - merge_request: false, - yaml_errors: false, - retryable: false, - cancelable: false, - failure_reason: false, + project: { + __typename: 'Project', + name: 'elemenohpee', + fullPath: 'root/elemenohpee', }, - details: { - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-runner/pipelines/23211253', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - duration: 53, - finished_at: '2018-10-31T16:35:31.299Z', - stages: [ - { - name: 'prebuild', - title: 'prebuild: passed', - groups: [ - { - name: 'review-docs-deploy', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'manual play action', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-runner/-/jobs/72469032', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-org/gitlab-runner/-/jobs/72469032/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 72469032, - name: 'review-docs-deploy', - started: '2018-10-31T16:34:58.778Z', - archived: false, - build_path: '/gitlab-org/gitlab-runner/-/jobs/72469032', - retry_path: '/gitlab-org/gitlab-runner/-/jobs/72469032/retry', - play_path: '/gitlab-org/gitlab-runner/-/jobs/72469032/play', - playable: true, - scheduled: false, - created_at: '2018-06-05T11:31:30.495Z', - updated_at: '2018-10-31T16:35:31.251Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'manual play action', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-runner/-/jobs/72469032', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-org/gitlab-runner/-/jobs/72469032/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-runner/pipelines/23211253#prebuild', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - path: '/gitlab-org/gitlab-runner/pipelines/23211253#prebuild', - dropdown_path: '/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=prebuild', - }, - { - name: 'test', - title: 'test: passed', - groups: [ - { - name: 'docs check links', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-runner/-/jobs/72469033', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-runner/-/jobs/72469033/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 72469033, - name: 'docs check links', - started: '2018-06-05T11:31:33.240Z', - archived: false, - build_path: '/gitlab-org/gitlab-runner/-/jobs/72469033', - retry_path: '/gitlab-org/gitlab-runner/-/jobs/72469033/retry', - playable: false, - scheduled: false, - created_at: '2018-06-05T11:31:30.627Z', - updated_at: '2018-06-05T11:31:54.363Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-runner/-/jobs/72469033', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-runner/-/jobs/72469033/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-runner/pipelines/23211253#test', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - path: '/gitlab-org/gitlab-runner/pipelines/23211253#test', - dropdown_path: '/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=test', - }, - { - name: 'cleanup', - title: 'cleanup: skipped', - groups: [ - { - name: 'review-docs-cleanup', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual stop action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-org/gitlab-runner/-/jobs/72469034', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'stop', - title: 'Stop', - path: '/gitlab-org/gitlab-runner/-/jobs/72469034/play', - method: 'post', - button_title: 'Stop this environment', - }, - }, - jobs: [ - { - id: 72469034, - name: 'review-docs-cleanup', - started: null, - archived: false, - build_path: '/gitlab-org/gitlab-runner/-/jobs/72469034', - play_path: '/gitlab-org/gitlab-runner/-/jobs/72469034/play', - playable: true, - scheduled: false, - created_at: '2018-06-05T11:31:30.760Z', - updated_at: '2018-06-05T11:31:56.037Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual stop action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-org/gitlab-runner/-/jobs/72469034', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'stop', - title: 'Stop', - path: '/gitlab-org/gitlab-runner/-/jobs/72469034/play', - method: 'post', - button_title: 'Stop this environment', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-org/gitlab-runner/pipelines/23211253#cleanup', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - path: '/gitlab-org/gitlab-runner/pipelines/23211253#cleanup', - dropdown_path: '/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=cleanup', - }, - ], - artifacts: [], - manual_actions: [ - { - name: 'review-docs-cleanup', - path: '/gitlab-org/gitlab-runner/-/jobs/72469034/play', - playable: true, - scheduled: false, - }, - { - name: 'review-docs-deploy', - path: '/gitlab-org/gitlab-runner/-/jobs/72469032/play', - playable: true, - scheduled: false, - }, - ], - scheduled_actions: [], - }, - ref: { - name: 'docs/add-development-guide-to-readme', - path: '/gitlab-org/gitlab-runner/commits/docs/add-development-guide-to-readme', - tag: false, - branch: true, - merge_request: false, - }, - commit: { - id: '8083eb0a920572214d0dccedd7981f05d535ad46', - short_id: '8083eb0a', - title: 'Add link to development guide in readme', - created_at: '2018-06-05T11:30:48.000Z', - parent_ids: ['1d7cf79b5a1a2121b9474ac20d61c1b8f621289d'], - message: - 'Add link to development guide in readme\n\nCloses https://gitlab.com/gitlab-org/gitlab-runner/issues/3122\n', - author_name: 'Achilleas Pipinellis', - author_email: 'axil@gitlab.com', - authored_date: '2018-06-05T11:30:48.000Z', - committer_name: 'Achilleas Pipinellis', - committer_email: 'axil@gitlab.com', - committed_date: '2018-06-05T11:30:48.000Z', - author: { - id: 3585, - name: 'Achilleas Pipinellis', - username: 'axil', - state: 'active', - avatar_url: 'https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png', - web_url: 'https://gitlab.com/axil', - status_tooltip_html: null, - path: '/axil', - }, - author_gravatar_url: - 'https://secure.gravatar.com/avatar/1d37af00eec153a8333a4ce18e9aea41?s=80\u0026d=identicon', - commit_url: - 'https://gitlab.com/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46', - commit_path: '/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46', - }, - project: { id: 20 }, - triggered_by: { - id: 12, - user: { - id: 376774, - name: 'Alessio Caiazza', - username: 'nolith', - state: 'active', - avatar_url: 'https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png', - web_url: 'https://gitlab.com/nolith', - status_tooltip_html: null, - path: '/nolith', - }, - active: false, - coverage: null, - source: 'pipeline', - source_job: { - name: 'trigger_job', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051', - details: { - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - }, - duration: 118, - finished_at: '2018-10-31T16:41:40.615Z', - stages: [ - { - name: 'build-images', - title: 'build-images: skipped', - groups: [ - { - name: 'image:bootstrap', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 11421321982853, - name: 'image:bootstrap', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.704Z', - updated_at: '2018-10-31T16:35:24.118Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - { - name: 'image:builder-onbuild', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 1149822131854, - name: 'image:builder-onbuild', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.728Z', - updated_at: '2018-10-31T16:35:24.070Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - { - name: 'image:nginx-onbuild', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 11498285523424, - name: 'image:nginx-onbuild', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.753Z', - updated_at: '2018-10-31T16:35:24.033Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images', - dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images', - }, - { - name: 'build', - title: 'build: failed', - groups: [ - { - name: 'compile_dev', - size: 1, - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed - (script failure)', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/528/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 1149846949786, - name: 'compile_dev', - started: '2018-10-31T16:39:41.598Z', - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - retry_path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:39:41.138Z', - updated_at: '2018-10-31T16:41:40.072Z', - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed - (script failure)', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/528/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - recoverable: false, - }, - ], - }, - ], - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#build', - dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build', - }, - { - name: 'deploy', - title: 'deploy: skipped', - groups: [ - { - name: 'review', - size: 1, - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - jobs: [ - { - id: 11498282342357, - name: 'review', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:35:23.805Z', - updated_at: '2018-10-31T16:41:40.569Z', - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - }, - ], - }, - { - name: 'review_stop', - size: 1, - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - jobs: [ - { - id: 114982858, - name: 'review_stop', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:35:23.840Z', - updated_at: '2018-10-31T16:41:40.480Z', - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - }, - ], - }, - ], - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy', - dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy', - }, - ], - artifacts: [], - manual_actions: [ - { - name: 'image:bootstrap', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - playable: true, - scheduled: false, - }, - { - name: 'image:builder-onbuild', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - playable: true, - scheduled: false, - }, - { - name: 'image:nginx-onbuild', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - playable: true, - scheduled: false, - }, - { - name: 'review_stop', - path: '/gitlab-com/gitlab-docs/-/jobs/114982858/play', - playable: false, - scheduled: false, - }, - ], - scheduled_actions: [], - }, - project: { - id: 20, - name: 'Test', - full_path: '/gitlab-com/gitlab-docs', - full_name: 'GitLab.com / GitLab Docs', - }, - triggered_by: { - id: 349932310342451, - user: { - id: 376774, - name: 'Alessio Caiazza', - username: 'nolith', - state: 'active', - avatar_url: - 'https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png', - web_url: 'https://gitlab.com/nolith', - status_tooltip_html: null, - path: '/nolith', - }, - active: false, - coverage: null, - source: 'pipeline', - source_job: { - name: 'trigger_job', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051', - details: { - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - }, - duration: 118, - finished_at: '2018-10-31T16:41:40.615Z', - stages: [ - { - name: 'build-images', - title: 'build-images: skipped', - groups: [ - { - name: 'image:bootstrap', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 11421321982853, - name: 'image:bootstrap', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.704Z', - updated_at: '2018-10-31T16:35:24.118Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - { - name: 'image:builder-onbuild', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 1149822131854, - name: 'image:builder-onbuild', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.728Z', - updated_at: '2018-10-31T16:35:24.070Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - { - name: 'image:nginx-onbuild', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 11498285523424, - name: 'image:nginx-onbuild', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.753Z', - updated_at: '2018-10-31T16:35:24.033Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images', - dropdown_path: - '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images', - }, - { - name: 'build', - title: 'build: failed', - groups: [ - { - name: 'compile_dev', - size: 1, - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed - (script failure)', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 1149846949786, - name: 'compile_dev', - started: '2018-10-31T16:39:41.598Z', - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - retry_path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:39:41.138Z', - updated_at: '2018-10-31T16:41:40.072Z', - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed - (script failure)', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - recoverable: false, - }, - ], - }, - ], - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#build', - dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build', - }, - { - name: 'deploy', - title: 'deploy: skipped', - groups: [ - { - name: 'review', - size: 1, - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - jobs: [ - { - id: 11498282342357, - name: 'review', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:35:23.805Z', - updated_at: '2018-10-31T16:41:40.569Z', - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - }, - ], - }, - { - name: 'review_stop', - size: 1, - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - jobs: [ - { - id: 114982858, - name: 'review_stop', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:35:23.840Z', - updated_at: '2018-10-31T16:41:40.480Z', - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - }, - ], - }, - ], - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy', - dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy', - }, - ], - artifacts: [], - manual_actions: [ - { - name: 'image:bootstrap', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - playable: true, - scheduled: false, - }, - { - name: 'image:builder-onbuild', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - playable: true, - scheduled: false, - }, - { - name: 'image:nginx-onbuild', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - playable: true, - scheduled: false, - }, - { - name: 'review_stop', - path: '/gitlab-com/gitlab-docs/-/jobs/114982858/play', - playable: false, - scheduled: false, - }, - ], - scheduled_actions: [], - }, - project: { - id: 20, - name: 'GitLab Docs', - full_path: '/gitlab-com/gitlab-docs', - full_name: 'GitLab.com / GitLab Docs', - }, - }, - triggered: [], - }, - triggered: [ - { - id: 34993051, - user: { - id: 376774, - name: 'Alessio Caiazza', - username: 'nolith', - state: 'active', - avatar_url: - 'https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png', - web_url: 'https://gitlab.com/nolith', - status_tooltip_html: null, - path: '/nolith', - }, - active: false, - coverage: null, - source: 'pipeline', - source_job: { - name: 'trigger_job', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051', - details: { - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - }, - duration: 118, - finished_at: '2018-10-31T16:41:40.615Z', - stages: [ - { - name: 'build-images', - title: 'build-images: skipped', - groups: [ - { - name: 'image:bootstrap', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 114982853, - name: 'image:bootstrap', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.704Z', - updated_at: '2018-10-31T16:35:24.118Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - { - name: 'image:builder-onbuild', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 114982854, - name: 'image:builder-onbuild', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.728Z', - updated_at: '2018-10-31T16:35:24.070Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - { - name: 'image:nginx-onbuild', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 114982855, - name: 'image:nginx-onbuild', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.753Z', - updated_at: '2018-10-31T16:35:24.033Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images', - dropdown_path: - '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images', - }, - { - name: 'build', - title: 'build: failed', - groups: [ - { - name: 'compile_dev', - size: 1, - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed - (script failure)', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/528/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 114984694, - name: 'compile_dev', - started: '2018-10-31T16:39:41.598Z', - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - retry_path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:39:41.138Z', - updated_at: '2018-10-31T16:41:40.072Z', - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed - (script failure)', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/528/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - recoverable: false, - }, - ], - }, - ], - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#build', - dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build', - }, - { - name: 'deploy', - title: 'deploy: skipped', - groups: [ - { - name: 'review', - size: 1, - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - jobs: [ - { - id: 114982857, - name: 'review', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:35:23.805Z', - updated_at: '2018-10-31T16:41:40.569Z', - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - }, - ], - }, - { - name: 'review_stop', - size: 1, - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - jobs: [ - { - id: 114982858, - name: 'review_stop', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:35:23.840Z', - updated_at: '2018-10-31T16:41:40.480Z', - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - }, - ], - }, - ], - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy', - dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy', - }, - ], - artifacts: [], - manual_actions: [ - { - name: 'image:bootstrap', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - playable: true, - scheduled: false, - }, - { - name: 'image:builder-onbuild', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - playable: true, - scheduled: false, - }, - { - name: 'image:nginx-onbuild', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - playable: true, - scheduled: false, - }, - { - name: 'review_stop', - path: '/gitlab-com/gitlab-docs/-/jobs/114982858/play', - playable: false, - scheduled: false, - }, - ], - scheduled_actions: [], - }, - project: { - id: 20, - name: 'GitLab Docs', - full_path: '/gitlab-com/gitlab-docs', - full_name: 'GitLab.com / GitLab Docs', - }, - }, - { - id: 34993052, - user: { - id: 376774, - name: 'Alessio Caiazza', - username: 'nolith', - state: 'active', - avatar_url: - 'https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png', - web_url: 'https://gitlab.com/nolith', - status_tooltip_html: null, - path: '/nolith', - }, - active: false, - coverage: null, - source: 'pipeline', - source_job: { - name: 'trigger_job', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051', - details: { - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - }, - duration: 118, - finished_at: '2018-10-31T16:41:40.615Z', - stages: [ - { - name: 'build-images', - title: 'build-images: skipped', - groups: [ - { - name: 'image:bootstrap', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 114982853, - name: 'image:bootstrap', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.704Z', - updated_at: '2018-10-31T16:35:24.118Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - { - name: 'image:builder-onbuild', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 114982854, - name: 'image:builder-onbuild', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.728Z', - updated_at: '2018-10-31T16:35:24.070Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - { - name: 'image:nginx-onbuild', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 1224982855, - name: 'image:nginx-onbuild', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - play_path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - playable: true, - scheduled: false, - created_at: '2018-10-31T16:35:23.753Z', - updated_at: '2018-10-31T16:35:24.033Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images', - dropdown_path: - '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images', - }, - { - name: 'build', - title: 'build: failed', - groups: [ - { - name: 'compile_dev', - size: 1, - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed - (script failure)', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 1123984694, - name: 'compile_dev', - started: '2018-10-31T16:39:41.598Z', - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - retry_path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:39:41.138Z', - updated_at: '2018-10-31T16:41:40.072Z', - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed - (script failure)', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - recoverable: false, - }, - ], - }, - ], - status: { - icon: 'status_failed', - text: 'failed', - label: 'failed', - group: 'failed', - tooltip: 'failed', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#build', - dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build', - }, - { - name: 'deploy', - title: 'deploy: skipped', - groups: [ - { - name: 'review', - size: 1, - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - jobs: [ - { - id: 1143232982857, - name: 'review', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:35:23.805Z', - updated_at: '2018-10-31T16:41:40.569Z', - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - }, - ], - }, - { - name: 'review_stop', - size: 1, - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - jobs: [ - { - id: 114921313182858, - name: 'review_stop', - started: null, - archived: false, - build_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - playable: false, - scheduled: false, - created_at: '2018-10-31T16:35:23.840Z', - updated_at: '2018-10-31T16:41:40.480Z', - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858', - illustration: { - image: - 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - }, - ], - }, - ], - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy', - illustration: null, - favicon: - 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy', - dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy', - }, - ], - artifacts: [], - manual_actions: [ - { - name: 'image:bootstrap', - path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play', - playable: true, - scheduled: false, - }, - { - name: 'image:builder-onbuild', - path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play', - playable: true, - scheduled: false, - }, - { - name: 'image:nginx-onbuild', - path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play', - playable: true, - scheduled: false, - }, - { - name: 'review_stop', - path: '/gitlab-com/gitlab-docs/-/jobs/114982858/play', - playable: false, - scheduled: false, - }, - ], - scheduled_actions: [], - }, - project: { - id: 20, - name: 'GitLab Docs', - full_path: '/gitlab-com/gitlab-docs', - full_name: 'GitLab.com / GitLab Docs', - }, - triggered: [ - { - id: 26, - user: null, - active: false, - coverage: null, - source: 'push', - source_job: { - name: 'trigger_job', - }, - created_at: '2019-01-06T17:48:37.599Z', - updated_at: '2019-01-06T17:48:38.371Z', - path: '/h5bp/html5-boilerplate/pipelines/26', - flags: { - latest: true, - stuck: false, - auto_devops: false, - merge_request: false, - yaml_errors: false, - retryable: true, - cancelable: false, - failure_reason: false, - }, - details: { - status: { - icon: 'status_warning', - text: 'passed', - label: 'passed with warnings', - group: 'success-with-warnings', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/pipelines/26', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - duration: null, - finished_at: '2019-01-06T17:48:38.370Z', - stages: [ - { - name: 'build', - title: 'build: passed', - groups: [ - { - name: 'build:linux', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/526', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/526/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 526, - name: 'build:linux', - started: '2019-01-06T08:48:20.236Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/526', - retry_path: '/h5bp/html5-boilerplate/-/jobs/526/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:37.806Z', - updated_at: '2019-01-06T17:48:37.806Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/526', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/526/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - { - name: 'build:osx', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/527', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/527/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 527, - name: 'build:osx', - started: '2019-01-06T07:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/527', - retry_path: '/h5bp/html5-boilerplate/-/jobs/527/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:37.846Z', - updated_at: '2019-01-06T17:48:37.846Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/527', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/527/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/pipelines/26#build', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - path: '/h5bp/html5-boilerplate/pipelines/26#build', - dropdown_path: '/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=build', - }, - { - name: 'test', - title: 'test: passed with warnings', - groups: [ - { - name: 'jenkins', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: null, - group: 'success', - tooltip: null, - has_details: false, - details_path: null, - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - jobs: [ - { - id: 546, - name: 'jenkins', - started: '2019-01-06T11:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/546', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:38.359Z', - updated_at: '2019-01-06T17:48:38.359Z', - status: { - icon: 'status_success', - text: 'passed', - label: null, - group: 'success', - tooltip: null, - has_details: false, - details_path: null, - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - }, - ], - }, - { - name: 'rspec:linux', - size: 3, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: false, - details_path: null, - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - jobs: [ - { - id: 528, - name: 'rspec:linux 0 3', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/528', - retry_path: '/h5bp/html5-boilerplate/-/jobs/528/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:37.885Z', - updated_at: '2019-01-06T17:48:37.885Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/528', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/528/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - { - id: 529, - name: 'rspec:linux 1 3', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/529', - retry_path: '/h5bp/html5-boilerplate/-/jobs/529/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:37.907Z', - updated_at: '2019-01-06T17:48:37.907Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/529', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/529/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - { - id: 530, - name: 'rspec:linux 2 3', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/530', - retry_path: '/h5bp/html5-boilerplate/-/jobs/530/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:37.927Z', - updated_at: '2019-01-06T17:48:37.927Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/530', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/530/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - { - name: 'rspec:osx', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/535', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/535/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 535, - name: 'rspec:osx', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/535', - retry_path: '/h5bp/html5-boilerplate/-/jobs/535/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:38.018Z', - updated_at: '2019-01-06T17:48:38.018Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/535', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/535/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - { - name: 'rspec:windows', - size: 3, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: false, - details_path: null, - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - jobs: [ - { - id: 531, - name: 'rspec:windows 0 3', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/531', - retry_path: '/h5bp/html5-boilerplate/-/jobs/531/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:37.944Z', - updated_at: '2019-01-06T17:48:37.944Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/531', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/531/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - { - id: 532, - name: 'rspec:windows 1 3', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/532', - retry_path: '/h5bp/html5-boilerplate/-/jobs/532/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:37.962Z', - updated_at: '2019-01-06T17:48:37.962Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/532', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/532/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - { - id: 534, - name: 'rspec:windows 2 3', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/534', - retry_path: '/h5bp/html5-boilerplate/-/jobs/534/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:37.999Z', - updated_at: '2019-01-06T17:48:37.999Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/534', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/534/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - { - name: 'spinach:linux', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/536', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/536/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 536, - name: 'spinach:linux', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/536', - retry_path: '/h5bp/html5-boilerplate/-/jobs/536/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:38.050Z', - updated_at: '2019-01-06T17:48:38.050Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/536', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/536/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - { - name: 'spinach:osx', - size: 1, - status: { - icon: 'status_warning', - text: 'failed', - label: 'failed (allowed to fail)', - group: 'failed-with-warnings', - tooltip: 'failed - (unknown failure) (allowed to fail)', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/537', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/537/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 537, - name: 'spinach:osx', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/537', - retry_path: '/h5bp/html5-boilerplate/-/jobs/537/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:38.069Z', - updated_at: '2019-01-06T17:48:38.069Z', - status: { - icon: 'status_warning', - text: 'failed', - label: 'failed (allowed to fail)', - group: 'failed-with-warnings', - tooltip: 'failed - (unknown failure) (allowed to fail)', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/537', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/537/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - callout_message: 'There is an unknown failure, please try again', - recoverable: true, - }, - ], - }, - ], - status: { - icon: 'status_warning', - text: 'passed', - label: 'passed with warnings', - group: 'success-with-warnings', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/pipelines/26#test', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - path: '/h5bp/html5-boilerplate/pipelines/26#test', - dropdown_path: '/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=test', - }, - { - name: 'security', - title: 'security: passed', - groups: [ - { - name: 'container_scanning', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/541', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/541/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 541, - name: 'container_scanning', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/541', - retry_path: '/h5bp/html5-boilerplate/-/jobs/541/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:38.186Z', - updated_at: '2019-01-06T17:48:38.186Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/541', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/541/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - { - name: 'dast', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/538', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/538/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 538, - name: 'dast', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/538', - retry_path: '/h5bp/html5-boilerplate/-/jobs/538/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:38.087Z', - updated_at: '2019-01-06T17:48:38.087Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/538', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/538/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - { - name: 'dependency_scanning', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/540', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/540/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 540, - name: 'dependency_scanning', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/540', - retry_path: '/h5bp/html5-boilerplate/-/jobs/540/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:38.153Z', - updated_at: '2019-01-06T17:48:38.153Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/540', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/540/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - { - name: 'sast', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/539', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/539/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 539, - name: 'sast', - started: '2019-01-06T09:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/539', - retry_path: '/h5bp/html5-boilerplate/-/jobs/539/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:38.121Z', - updated_at: '2019-01-06T17:48:38.121Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/539', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/539/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/pipelines/26#security', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - path: '/h5bp/html5-boilerplate/pipelines/26#security', - dropdown_path: '/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=security', - }, - { - name: 'deploy', - title: 'deploy: passed', - groups: [ - { - name: 'production', - size: 1, - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/544', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - '/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - jobs: [ - { - id: 544, - name: 'production', - started: null, - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/544', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:38.313Z', - updated_at: '2019-01-06T17:48:38.313Z', - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/544', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - '/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - }, - ], - }, - { - name: 'staging', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/542', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/542/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - jobs: [ - { - id: 542, - name: 'staging', - started: '2019-01-06T11:48:20.237Z', - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/542', - retry_path: '/h5bp/html5-boilerplate/-/jobs/542/retry', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:38.219Z', - updated_at: '2019-01-06T17:48:38.219Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/542', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/h5bp/html5-boilerplate/-/jobs/542/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - }, - ], - }, - { - name: 'stop staging', - size: 1, - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/543', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - '/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - jobs: [ - { - id: 543, - name: 'stop staging', - started: null, - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/543', - playable: false, - scheduled: false, - created_at: '2019-01-06T17:48:38.283Z', - updated_at: '2019-01-06T17:48:38.283Z', - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - tooltip: 'skipped', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/543', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg', - size: 'svg-430', - title: 'This job has been skipped', - }, - favicon: - '/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png', - }, - }, - ], - }, - ], - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/pipelines/26#deploy', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - path: '/h5bp/html5-boilerplate/pipelines/26#deploy', - dropdown_path: '/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=deploy', - }, - { - name: 'notify', - title: 'notify: passed', - groups: [ - { - name: 'slack', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'manual play action', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/545', - illustration: { - image: - '/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'play', - title: 'Play', - path: '/h5bp/html5-boilerplate/-/jobs/545/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - jobs: [ - { - id: 545, - name: 'slack', - started: null, - archived: false, - build_path: '/h5bp/html5-boilerplate/-/jobs/545', - retry_path: '/h5bp/html5-boilerplate/-/jobs/545/retry', - play_path: '/h5bp/html5-boilerplate/-/jobs/545/play', - playable: true, - scheduled: false, - created_at: '2019-01-06T17:48:38.341Z', - updated_at: '2019-01-06T17:48:38.341Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'manual play action', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/-/jobs/545', - illustration: { - image: - '/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'play', - title: 'Play', - path: '/h5bp/html5-boilerplate/-/jobs/545/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/h5bp/html5-boilerplate/pipelines/26#notify', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - path: '/h5bp/html5-boilerplate/pipelines/26#notify', - dropdown_path: '/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=notify', - }, - ], - artifacts: [ - { - name: 'build:linux', - expired: null, - expire_at: null, - path: '/h5bp/html5-boilerplate/-/jobs/526/artifacts/download', - browse_path: '/h5bp/html5-boilerplate/-/jobs/526/artifacts/browse', - }, - { - name: 'build:osx', - expired: null, - expire_at: null, - path: '/h5bp/html5-boilerplate/-/jobs/527/artifacts/download', - browse_path: '/h5bp/html5-boilerplate/-/jobs/527/artifacts/browse', - }, - ], - manual_actions: [ - { - name: 'stop staging', - path: '/h5bp/html5-boilerplate/-/jobs/543/play', - playable: false, - scheduled: false, - }, - { - name: 'production', - path: '/h5bp/html5-boilerplate/-/jobs/544/play', - playable: false, - scheduled: false, - }, - { - name: 'slack', - path: '/h5bp/html5-boilerplate/-/jobs/545/play', - playable: true, - scheduled: false, - }, - ], - scheduled_actions: [], - }, - ref: { - name: 'main', - path: '/h5bp/html5-boilerplate/commits/main', - tag: false, - branch: true, - merge_request: false, - }, - commit: { - id: 'bad98c453eab56d20057f3929989251d45cd1a8b', - short_id: 'bad98c45', - title: 'remove instances of shrink-to-fit=no (#2103)', - created_at: '2018-12-17T20:52:18.000Z', - parent_ids: ['49130f6cfe9ff1f749015d735649a2bc6f66cf3a'], - message: - 'remove instances of shrink-to-fit=no (#2103)\n\ncloses #2102\r\n\r\nPer my findings, the need for it as a default was rectified with the release of iOS 9.3, where the viewport no longer shrunk to accommodate overflow, as was introduced in iOS 9.', - author_name: "Scott O'Hara", - author_email: 'scottaohara@users.noreply.github.com', - authored_date: '2018-12-17T20:52:18.000Z', - committer_name: 'Rob Larsen', - committer_email: 'rob@drunkenfist.com', - committed_date: '2018-12-17T20:52:18.000Z', - author: null, - author_gravatar_url: - 'https://www.gravatar.com/avatar/6d597df7cf998d16cbe00ccac063b31e?s=80\u0026d=identicon', - commit_url: - 'http://localhost:3001/h5bp/html5-boilerplate/commit/bad98c453eab56d20057f3929989251d45cd1a8b', - commit_path: '/h5bp/html5-boilerplate/commit/bad98c453eab56d20057f3929989251d45cd1a8b', - }, - retry_path: '/h5bp/html5-boilerplate/pipelines/26/retry', - triggered_by: { - id: 4, - user: null, - active: false, - coverage: null, - source: 'push', - source_job: { - name: 'trigger_job', - }, - path: '/gitlab-org/gitlab-test/pipelines/4', - details: { - status: { - icon: 'status_warning', - text: 'passed', - label: 'passed with warnings', - group: 'success-with-warnings', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-test/pipelines/4', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - }, - project: { - id: 1, - name: 'Gitlab Test', - full_path: '/gitlab-org/gitlab-test', - full_name: 'Gitlab Org / Gitlab Test', - }, - }, - triggered: [], - project: { - id: 20, - name: 'GitLab Docs', - full_path: '/gitlab-com/gitlab-docs', - full_name: 'GitLab.com / GitLab Docs', - }, - }, - ], - }, - ], + multiproject: true, }; diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js index e531e26a858..9e51003da66 100644 --- a/spec/frontend/pipelines/header_component_spec.js +++ b/spec/frontend/pipelines/header_component_spec.js @@ -24,7 +24,7 @@ describe('Pipeline details header', () => { const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const defaultProvideOptions = { - pipelineId: 14, + pipelineId: '14', pipelineIid: 1, paths: { pipelinesPath: '/namespace/my-project/-/pipelines', diff --git a/spec/frontend/pipelines/pipeline_multi_actions_spec.js b/spec/frontend/pipelines/pipeline_multi_actions_spec.js index ce33b6011bf..a606595b37d 100644 --- a/spec/frontend/pipelines/pipeline_multi_actions_spec.js +++ b/spec/frontend/pipelines/pipeline_multi_actions_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlDropdown, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlDropdown, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -51,6 +51,7 @@ describe('Pipeline Multi Actions Dropdown', () => { const findAlert = () => wrapper.findComponent(GlAlert); const findDropdown = () => wrapper.findComponent(GlDropdown); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAllArtifactItems = () => wrapper.findAllByTestId(artifactItemTestId); const findFirstArtifactItem = () => wrapper.findByTestId(artifactItemTestId); const findEmptyMessage = () => wrapper.findByTestId('artifacts-empty-message'); @@ -103,6 +104,15 @@ describe('Pipeline Multi Actions Dropdown', () => { expect(findEmptyMessage().exists()).toBe(true); }); + describe('while loading artifacts', () => { + it('should render a loading spinner and no empty message', () => { + createComponent({ mockData: { isLoading: true, artifacts: [] } }); + + expect(findLoadingIcon().exists()).toBe(true); + expect(findEmptyMessage().exists()).toBe(false); + }); + }); + describe('with a failing request', () => { it('should render an error message', async () => { const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId); diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index 76feaaad1ec..aa30062c987 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -105,8 +105,6 @@ describe('Pipelines', () => { }); beforeEach(() => { - window.gon = { features: { pipelineSourceFilter: true } }; - mock = new MockAdapter(axios); jest.spyOn(window.history, 'pushState'); diff --git a/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js index 5d15f0a3c55..684d2d0664a 100644 --- a/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js @@ -1,5 +1,6 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { PIPELINE_SOURCES } from 'ee_else_ce/pipelines/components/pipelines_list/tokens/constants'; import { stubComponent } from 'helpers/stub_component'; import PipelineSourceToken from '~/pipelines/components/pipelines_list/tokens/pipeline_source_token.vue'; @@ -44,7 +45,7 @@ describe('Pipeline Source Token', () => { describe('shows sources correctly', () => { it('renders all pipeline sources available', () => { - expect(findAllFilteredSearchSuggestions()).toHaveLength(wrapper.vm.sources.length); + expect(findAllFilteredSearchSuggestions()).toHaveLength(PIPELINE_SOURCES.length); }); }); }); diff --git a/spec/frontend/pipelines/parsing_utils_spec.js b/spec/frontend/pipelines/utils_spec.js index 3a270c1c1b5..1c23a7e4fcf 100644 --- a/spec/frontend/pipelines/parsing_utils_spec.js +++ b/spec/frontend/pipelines/utils_spec.js @@ -1,6 +1,5 @@ import { createSankey } from '~/pipelines/components/dag/drawing_utils'; import { - createNodeDict, makeLinksFromNodes, filterByAncestors, generateColumnsFromLayersListBare, @@ -9,6 +8,7 @@ import { removeOrphanNodes, getMaxNodes, } from '~/pipelines/components/parsing_utils'; +import { createNodeDict } from '~/pipelines/utils'; import { mockParsedGraphQLNodes, missingJob } from './components/dag/mock_data'; import { generateResponse, mockPipelineResponse } from './graph/mock_data'; diff --git a/spec/frontend/pipelines_spec.js b/spec/frontend/pipelines_spec.js deleted file mode 100644 index add91fbcc23..00000000000 --- a/spec/frontend/pipelines_spec.js +++ /dev/null @@ -1,17 +0,0 @@ -import Pipelines from '~/pipelines'; - -describe('Pipelines', () => { - beforeEach(() => { - loadFixtures('static/pipeline_graph.html'); - }); - - it('should be defined', () => { - expect(Pipelines).toBeDefined(); - }); - - it('should create a `Pipelines` instance without options', () => { - expect(() => { - new Pipelines(); // eslint-disable-line no-new - }).not.toThrow(); - }); -}); diff --git a/spec/frontend/popovers/components/popovers_spec.js b/spec/frontend/popovers/components/popovers_spec.js index 25c509346d1..2751a878e51 100644 --- a/spec/frontend/popovers/components/popovers_spec.js +++ b/spec/frontend/popovers/components/popovers_spec.js @@ -54,17 +54,20 @@ describe('popovers/components/popovers.vue', () => { expect(wrapper.findAll(GlPopover)).toHaveLength(1); }); - it('supports HTML content', async () => { - const content = 'content with <b>HTML</b>'; - await buildWrapper( - createPopoverTarget({ - content, - html: true, - }), - ); - const html = wrapper.find(GlPopover).html(); - - expect(html).toContain(content); + describe('supports HTML content', () => { + const svgIcon = '<svg><use xlink:href="icons.svg#test"></use></svg>'; + + it.each` + description | content | render + ${'renders html content correctly'} | ${'<b>HTML</b>'} | ${'<b>HTML</b>'} + ${'removes any unsafe content'} | ${'<script>alert(XSS)</script>'} | ${''} + ${'renders svg icons correctly'} | ${svgIcon} | ${svgIcon} + `('$description', async ({ content, render }) => { + await buildWrapper(createPopoverTarget({ content, html: true })); + + const html = wrapper.find(GlPopover).html(); + expect(html).toContain(render); + }); }); it.each` diff --git a/spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js b/spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js index b5ee62f2042..6ef49390c47 100644 --- a/spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js +++ b/spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js @@ -60,7 +60,7 @@ describe('~/projects/pipelines/charts/components/pipeline_charts.vue', () => { expect(chart.props('yAxisTitle')).toBe('Minutes'); expect(chart.props('xAxisTitle')).toBe('Commit'); expect(chart.props('bars')).toBe(wrapper.vm.timesChartTransformedData); - expect(chart.props('option')).toBe(wrapper.vm.$options.timesChartOptions); + expect(chart.props('option')).toBe(wrapper.vm.chartOptions); }); }); diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js index 5323c1afbb5..eacf858f22c 100644 --- a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js @@ -107,6 +107,29 @@ describe('ServiceDeskSetting', () => { }); }); + describe('project suffix', () => { + it('input is hidden', () => { + wrapper = createComponent({ + props: { customEmailEnabled: false }, + }); + + const input = wrapper.findByTestId('project-suffix'); + + expect(input.exists()).toBe(false); + }); + + it('input is enabled', () => { + wrapper = createComponent({ + props: { customEmailEnabled: true }, + }); + + const input = wrapper.findByTestId('project-suffix'); + + expect(input.exists()).toBe(true); + expect(input.attributes('disabled')).toBeUndefined(); + }); + }); + describe('customEmail is the same as incomingEmail', () => { const email = 'foo@bar.com'; diff --git a/spec/frontend/projects/storage_counter/components/app_spec.js b/spec/frontend/projects/storage_counter/components/app_spec.js new file mode 100644 index 00000000000..f3da01e0602 --- /dev/null +++ b/spec/frontend/projects/storage_counter/components/app_spec.js @@ -0,0 +1,150 @@ +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import StorageCounterApp from '~/projects/storage_counter/components/app.vue'; +import { TOTAL_USAGE_DEFAULT_TEXT } from '~/projects/storage_counter/constants'; +import getProjectStorageCount from '~/projects/storage_counter/queries/project_storage.query.graphql'; +import UsageGraph from '~/vue_shared/components/storage_counter/usage_graph.vue'; +import { + mockGetProjectStorageCountGraphQLResponse, + mockEmptyResponse, + projectData, + defaultProvideValues, +} from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('Storage counter app', () => { + let wrapper; + + const createMockApolloProvider = ({ reject = false, mockedValue } = {}) => { + let response; + + if (reject) { + response = jest.fn().mockRejectedValue(mockedValue || new Error('GraphQL error')); + } else { + response = jest.fn().mockResolvedValue(mockedValue); + } + + const requestHandlers = [[getProjectStorageCount, response]]; + + return createMockApollo(requestHandlers); + }; + + const createComponent = ({ provide = {}, mockApollo } = {}) => { + wrapper = extendedWrapper( + shallowMount(StorageCounterApp, { + localVue, + apolloProvider: mockApollo, + provide: { + ...defaultProvideValues, + ...provide, + }, + }), + ); + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findUsagePercentage = () => wrapper.findByTestId('total-usage'); + const findUsageQuotasHelpLink = () => wrapper.findByTestId('usage-quotas-help-link'); + const findUsageGraph = () => wrapper.findComponent(UsageGraph); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('with apollo fetching successful', () => { + let mockApollo; + + beforeEach(async () => { + mockApollo = createMockApolloProvider({ + mockedValue: mockGetProjectStorageCountGraphQLResponse, + }); + createComponent({ mockApollo }); + await waitForPromises(); + }); + + it('renders correct total usage', () => { + expect(findUsagePercentage().text()).toBe(projectData.storage.totalUsage); + }); + + it('renders correct usage quotas help link', () => { + expect(findUsageQuotasHelpLink().attributes('href')).toBe( + defaultProvideValues.helpLinks.usageQuotasHelpPagePath, + ); + }); + }); + + describe('with apollo loading', () => { + let mockApollo; + + beforeEach(() => { + mockApollo = createMockApolloProvider({ + mockedValue: new Promise(() => {}), + }); + createComponent({ mockApollo }); + }); + + it('should show loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('with apollo returning empty data', () => { + let mockApollo; + + beforeEach(async () => { + mockApollo = createMockApolloProvider({ + mockedValue: mockEmptyResponse, + }); + createComponent({ mockApollo }); + await waitForPromises(); + }); + + it('shows default text for total usage', () => { + expect(findUsagePercentage().text()).toBe(TOTAL_USAGE_DEFAULT_TEXT); + }); + }); + + describe('with apollo fetching error', () => { + let mockApollo; + + beforeEach(() => { + mockApollo = createMockApolloProvider(); + createComponent({ mockApollo, reject: true }); + }); + + it('renders gl-alert', () => { + expect(findAlert().exists()).toBe(true); + }); + }); + + describe('rendering <usage-graph />', () => { + let mockApollo; + + beforeEach(async () => { + mockApollo = createMockApolloProvider({ + mockedValue: mockGetProjectStorageCountGraphQLResponse, + }); + createComponent({ mockApollo }); + await waitForPromises(); + }); + + it('renders usage-graph component if project.statistics exists', () => { + expect(findUsageGraph().exists()).toBe(true); + }); + + it('passes project.statistics to usage-graph component', () => { + const { + __typename, + ...statistics + } = mockGetProjectStorageCountGraphQLResponse.data.project.statistics; + expect(findUsageGraph().props('rootStorageStatistics')).toMatchObject(statistics); + }); + }); +}); diff --git a/spec/frontend/projects/storage_counter/components/storage_table_spec.js b/spec/frontend/projects/storage_counter/components/storage_table_spec.js new file mode 100644 index 00000000000..14298318fff --- /dev/null +++ b/spec/frontend/projects/storage_counter/components/storage_table_spec.js @@ -0,0 +1,62 @@ +import { GlTable } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import StorageTable from '~/projects/storage_counter/components/storage_table.vue'; +import { projectData, defaultProvideValues } from '../mock_data'; + +describe('StorageTable', () => { + let wrapper; + + const defaultProps = { + storageTypes: projectData.storage.storageTypes, + }; + + const createComponent = (props = {}) => { + wrapper = extendedWrapper( + mount(StorageTable, { + propsData: { + ...defaultProps, + ...props, + }, + }), + ); + }; + + const findTable = () => wrapper.findComponent(GlTable); + + beforeEach(() => { + createComponent(); + }); + afterEach(() => { + wrapper.destroy(); + }); + + describe('with storage types', () => { + it.each(projectData.storage.storageTypes)( + 'renders table row correctly %o', + ({ storageType: { id, name, description } }) => { + expect(wrapper.findByTestId(`${id}-name`).text()).toBe(name); + expect(wrapper.findByTestId(`${id}-description`).text()).toBe(description); + expect(wrapper.findByTestId(`${id}-help-link`).attributes('href')).toBe( + defaultProvideValues.helpLinks[id.replace(`Size`, `HelpPagePath`)] + .replace(`Size`, ``) + .replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`), + ); + }, + ); + }); + + describe('without storage types', () => { + beforeEach(() => { + createComponent({ storageTypes: [] }); + }); + + it('should render the table header <th>', () => { + expect(findTable().find('th').exists()).toBe(true); + }); + + it('should not render any table data <td>', () => { + expect(findTable().find('td').exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/projects/storage_counter/mock_data.js b/spec/frontend/projects/storage_counter/mock_data.js new file mode 100644 index 00000000000..b9fa68b3ec7 --- /dev/null +++ b/spec/frontend/projects/storage_counter/mock_data.js @@ -0,0 +1,109 @@ +export const mockGetProjectStorageCountGraphQLResponse = { + data: { + project: { + id: 'gid://gitlab/Project/20', + statistics: { + buildArtifactsSize: 400000.0, + pipelineArtifactsSize: 25000.0, + lfsObjectsSize: 4800000.0, + packagesSize: 3800000.0, + repositorySize: 3900000.0, + snippetsSize: 1200000.0, + storageSize: 15300000.0, + uploadsSize: 900000.0, + wikiSize: 300000.0, + __typename: 'ProjectStatistics', + }, + __typename: 'Project', + }, + }, +}; + +export const mockEmptyResponse = { data: { project: null } }; + +export const defaultProvideValues = { + projectPath: '/project-path', + helpLinks: { + usageQuotasHelpPagePath: '/usage-quotas', + buildArtifactsHelpPagePath: '/build-artifacts', + lfsObjectsHelpPagePath: '/lsf-objects', + packagesHelpPagePath: '/packages', + repositoryHelpPagePath: '/repository', + snippetsHelpPagePath: '/snippets', + uploadsHelpPagePath: '/uploads', + wikiHelpPagePath: '/wiki', + }, +}; + +export const projectData = { + storage: { + totalUsage: '14.6 MiB', + storageTypes: [ + { + storageType: { + id: 'buildArtifactsSize', + name: 'Artifacts', + description: 'Pipeline artifacts and job artifacts, created with CI/CD.', + warningMessage: + 'There is a known issue with Artifact storage where the total could be incorrect for some projects. More details and progress are available in %{warningLinkStart}the epic%{warningLinkEnd}.', + helpPath: '/build-artifacts', + }, + value: 400000, + }, + { + storageType: { + id: 'lfsObjectsSize', + name: 'LFS Storage', + description: 'Audio samples, videos, datasets, and graphics.', + helpPath: '/lsf-objects', + }, + value: 4800000, + }, + { + storageType: { + id: 'packagesSize', + name: 'Packages', + description: 'Code packages and container images.', + helpPath: '/packages', + }, + value: 3800000, + }, + { + storageType: { + id: 'repositorySize', + name: 'Repository', + description: 'Git repository, managed by the Gitaly service.', + helpPath: '/repository', + }, + value: 3900000, + }, + { + storageType: { + id: 'snippetsSize', + name: 'Snippets', + description: 'Shared bits of code and text.', + helpPath: '/snippets', + }, + value: 1200000, + }, + { + storageType: { + id: 'uploadsSize', + name: 'Uploads', + description: 'File attachments and smaller design graphics.', + helpPath: '/uploads', + }, + value: 900000, + }, + { + storageType: { + id: 'wikiSize', + name: 'Wiki', + description: 'Wiki content.', + helpPath: '/wiki', + }, + value: 300000, + }, + ], + }, +}; diff --git a/spec/frontend/projects/storage_counter/utils_spec.js b/spec/frontend/projects/storage_counter/utils_spec.js new file mode 100644 index 00000000000..57c755266a0 --- /dev/null +++ b/spec/frontend/projects/storage_counter/utils_spec.js @@ -0,0 +1,17 @@ +import { parseGetProjectStorageResults } from '~/projects/storage_counter/utils'; +import { + mockGetProjectStorageCountGraphQLResponse, + projectData, + defaultProvideValues, +} from './mock_data'; + +describe('parseGetProjectStorageResults', () => { + it('parses project statistics correctly', () => { + expect( + parseGetProjectStorageResults( + mockGetProjectStorageCountGraphQLResponse.data, + defaultProvideValues.helpLinks, + ), + ).toMatchObject(projectData); + }); +}); diff --git a/spec/frontend/projects/terraform_notification/terraform_notification_spec.js b/spec/frontend/projects/terraform_notification/terraform_notification_spec.js index 71c22998b08..6576ce70d60 100644 --- a/spec/frontend/projects/terraform_notification/terraform_notification_spec.js +++ b/spec/frontend/projects/terraform_notification/terraform_notification_spec.js @@ -1,51 +1,91 @@ import { GlBanner } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { setCookie, parseBoolean } from '~/lib/utils/common_utils'; +import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser'; +import { mockTracking } from 'helpers/tracking_helper'; import TerraformNotification from '~/projects/terraform_notification/components/terraform_notification.vue'; - -jest.mock('~/lib/utils/common_utils'); +import { + EVENT_LABEL, + DISMISS_EVENT, + CLICK_EVENT, +} from '~/projects/terraform_notification/constants'; const terraformImagePath = '/path/to/image'; -const bannerDismissedKey = 'terraform_notification_dismissed'; describe('TerraformNotificationBanner', () => { let wrapper; + let trackingSpy; + let userCalloutDismissSpy; const provideData = { terraformImagePath, - bannerDismissedKey, }; const findBanner = () => wrapper.findComponent(GlBanner); - beforeEach(() => { + const createComponent = ({ shouldShowCallout = true } = {}) => { + userCalloutDismissSpy = jest.fn(); + wrapper = shallowMount(TerraformNotification, { provide: provideData, - stubs: { GlBanner }, + stubs: { + GlBanner, + UserCalloutDismisser: makeMockUserCalloutDismisser({ + dismiss: userCalloutDismissSpy, + shouldShowCallout, + }), + }, }); + }; + + beforeEach(() => { + createComponent(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }); afterEach(() => { wrapper.destroy(); - parseBoolean.mockReturnValue(false); }); - describe('when the dismiss cookie is not set', () => { + describe('when user has already dismissed the banner', () => { + beforeEach(() => { + createComponent({ + shouldShowCallout: false, + }); + }); + it('should not render the banner', () => { + expect(findBanner().exists()).toBe(false); + }); + }); + + describe("when user hasn't yet dismissed the banner", () => { it('should render the banner', () => { expect(findBanner().exists()).toBe(true); }); }); describe('when close button is clicked', () => { - beforeEach(async () => { - await findBanner().vm.$emit('close'); + beforeEach(() => { + wrapper.vm.$refs.calloutDismisser.dismiss = userCalloutDismissSpy; + findBanner().vm.$emit('close'); + }); + it('should send the dismiss event', () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, DISMISS_EVENT, { + label: EVENT_LABEL, + }); }); + it('should call the dismiss callback', () => { + expect(userCalloutDismissSpy).toHaveBeenCalledTimes(1); + }); + }); - it('should set the cookie with the bannerDismissedKey', () => { - expect(setCookie).toHaveBeenCalledWith(bannerDismissedKey, true); + describe('when docs link is clicked', () => { + beforeEach(() => { + findBanner().vm.$emit('primary'); }); - it('should remove the banner', () => { - expect(findBanner().exists()).toBe(false); + it('should send button click event', () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, CLICK_EVENT, { + label: EVENT_LABEL, + }); }); }); }); diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index d462995328b..8331adcdfc2 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -375,6 +375,30 @@ describe('Blob content viewer component', () => { expect(findBlobHeader().props('isBinary')).toBe(true); }, ); + + it('passes the correct header props when viewing a non-text file', async () => { + fullFactory({ + mockData: { + blobInfo: { + ...simpleMockData, + simpleViewer: { + ...simpleMockData.simpleViewer, + fileType: 'image', + }, + }, + }, + stubs: { + BlobContent: true, + BlobReplace: true, + }, + }); + + await nextTick(); + + expect(findBlobHeader().props('hideViewerSwitcher')).toBe(true); + expect(findBlobHeader().props('isBinary')).toBe(true); + expect(findBlobEdit().props('showEditButton')).toBe(false); + }); }); describe('BlobButtonGroup', () => { diff --git a/spec/frontend/repository/components/blob_viewers/image_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/image_viewer_spec.js new file mode 100644 index 00000000000..6735dddf51e --- /dev/null +++ b/spec/frontend/repository/components/blob_viewers/image_viewer_spec.js @@ -0,0 +1,25 @@ +import { shallowMount } from '@vue/test-utils'; +import ImageViewer from '~/repository/components/blob_viewers/image_viewer.vue'; + +describe('Image Viewer', () => { + let wrapper; + + const propsData = { + url: 'some/image.png', + alt: 'image.png', + }; + + const createComponent = () => { + wrapper = shallowMount(ImageViewer, { propsData }); + }; + + const findImage = () => wrapper.find('[data-testid="image"]'); + + it('renders a Source Editor component', () => { + createComponent(); + + expect(findImage().exists()).toBe(true); + expect(findImage().attributes('src')).toBe(propsData.url); + expect(findImage().attributes('alt')).toBe(propsData.alt); + }); +}); diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js index 1d1ec58100f..e36287eff29 100644 --- a/spec/frontend/repository/components/tree_content_spec.js +++ b/spec/frontend/repository/components/tree_content_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import filesQuery from 'shared_queries/repository/files.query.graphql'; +import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql'; import FilePreview from '~/repository/components/preview/index.vue'; import FileTable from '~/repository/components/table/index.vue'; import TreeContent from '~/repository/components/tree_content.vue'; @@ -22,6 +22,7 @@ function factory(path, data = () => ({})) { provide: { glFeatures: { increasePageSizeExponentially: true, + paginatedTreeGraphqlQuery: true, }, }, }); @@ -58,7 +59,7 @@ describe('Repository table component', () => { it('normalizes edge nodes', () => { factory('/'); - const output = vm.vm.normalizeData('blobs', [{ node: '1' }, { node: '2' }]); + const output = vm.vm.normalizeData('blobs', { nodes: ['1', '2'] }); expect(output).toEqual(['1', '2']); }); @@ -168,7 +169,7 @@ describe('Repository table component', () => { vm.vm.fetchFiles(); expect($apollo.query).toHaveBeenCalledWith({ - query: filesQuery, + query: paginatedTreeQuery, variables: { pageSize, nextPageCursor: '', diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js index c1596711be7..3292f635f6b 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -2,6 +2,7 @@ import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import { updateHistory } from '~/lib/utils/url_utility'; @@ -14,16 +15,20 @@ import RunnerPagination from '~/runner/components/runner_pagination.vue'; import RunnerTypeHelp from '~/runner/components/runner_type_help.vue'; import { + ADMIN_FILTERED_SEARCH_NAMESPACE, CREATED_ASC, CREATED_DESC, DEFAULT_SORT, INSTANCE_TYPE, PARAM_KEY_STATUS, + PARAM_KEY_RUNNER_TYPE, + PARAM_KEY_TAG, STATUS_ACTIVE, RUNNER_PAGE_SIZE, } from '~/runner/constants'; import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; import { captureException } from '~/runner/sentry_utils'; +import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import { runnersData, runnersDataPaginated } from '../mock_data'; @@ -47,10 +52,14 @@ describe('AdminRunnersApp', () => { const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp); const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp); const findRunnerList = () => wrapper.findComponent(RunnerList); - const findRunnerPagination = () => wrapper.findComponent(RunnerPagination); + const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); + const findRunnerPaginationPrev = () => + findRunnerPagination().findByLabelText('Go to previous page'); + const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page'); const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); + const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); - const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => { + const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { const handlers = [[getRunnersQuery, mockRunnersQuery]]; wrapper = mountFn(AdminRunnersApp, { @@ -68,7 +77,7 @@ describe('AdminRunnersApp', () => { setWindowLocation('/admin/runners'); mockRunnersQuery = jest.fn().mockResolvedValue(runnersData); - createComponentWithApollo(); + createComponent(); await waitForPromises(); }); @@ -77,8 +86,16 @@ describe('AdminRunnersApp', () => { wrapper.destroy(); }); + it('shows the runner type help', () => { + expect(findRunnerTypeHelp().exists()).toBe(true); + }); + + it('shows the runner setup instructions', () => { + expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken); + }); + it('shows the runners list', () => { - expect(runnersData.data.runners.nodes).toMatchObject(findRunnerList().props('runners')); + expect(findRunnerList().props('runners')).toEqual(runnersData.data.runners.nodes); }); it('requests the runners with no filters', () => { @@ -90,20 +107,38 @@ describe('AdminRunnersApp', () => { }); }); - it('shows the runner type help', () => { - expect(findRunnerTypeHelp().exists()).toBe(true); + it('sets tokens in the filtered search', () => { + createComponent({ mountFn: mount }); + + expect(findFilteredSearch().props('tokens')).toEqual([ + expect.objectContaining({ + type: PARAM_KEY_STATUS, + options: expect.any(Array), + }), + expect.objectContaining({ + type: PARAM_KEY_RUNNER_TYPE, + options: expect.any(Array), + }), + expect.objectContaining({ + type: PARAM_KEY_TAG, + recentTokenValuesStorageKey: `${ADMIN_FILTERED_SEARCH_NAMESPACE}-recent-tags`, + }), + ]); }); - it('shows the runner setup instructions', () => { - expect(findRunnerManualSetupHelp().exists()).toBe(true); - expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken); + it('shows the active runner count', () => { + createComponent({ mountFn: mount }); + + expect(findRunnerFilteredSearchBar().text()).toMatch( + `Runners currently online: ${mockActiveRunnersCount}`, + ); }); describe('when a filter is preselected', () => { beforeEach(async () => { setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`); - createComponentWithApollo(); + createComponent(); await waitForPromises(); }); @@ -133,7 +168,7 @@ describe('AdminRunnersApp', () => { describe('when a filter is selected by the user', () => { beforeEach(() => { findRunnerFilteredSearchBar().vm.$emit('input', { - filters: [{ type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } }], + filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }], sort: CREATED_ASC, }); }); @@ -154,11 +189,19 @@ describe('AdminRunnersApp', () => { }); }); + it('when runners have not loaded, shows a loading state', () => { + createComponent(); + expect(findRunnerList().props('loading')).toBe(true); + }); + describe('when no runners are found', () => { beforeEach(async () => { - mockRunnersQuery = jest.fn().mockResolvedValue({ data: { runners: { nodes: [] } } }); - createComponentWithApollo(); - await waitForPromises(); + mockRunnersQuery = jest.fn().mockResolvedValue({ + data: { + runners: { nodes: [] }, + }, + }); + createComponent(); }); it('shows a message for no results', async () => { @@ -166,17 +209,14 @@ describe('AdminRunnersApp', () => { }); }); - it('when runners have not loaded, shows a loading state', () => { - createComponentWithApollo(); - expect(findRunnerList().props('loading')).toBe(true); - }); - describe('when runners query fails', () => { - beforeEach(async () => { + beforeEach(() => { mockRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!')); - createComponentWithApollo(); + createComponent(); + }); - await waitForPromises(); + it('error is shown to the user', async () => { + expect(createFlash).toHaveBeenCalledTimes(1); }); it('error is reported to sentry', async () => { @@ -185,17 +225,13 @@ describe('AdminRunnersApp', () => { component: 'AdminRunnersApp', }); }); - - it('error is shown to the user', async () => { - expect(createFlash).toHaveBeenCalledTimes(1); - }); }); describe('Pagination', () => { beforeEach(() => { mockRunnersQuery = jest.fn().mockResolvedValue(runnersDataPaginated); - createComponentWithApollo({ mountFn: mount }); + createComponent({ mountFn: mount }); }); it('more pages can be selected', () => { @@ -203,14 +239,11 @@ describe('AdminRunnersApp', () => { }); it('cannot navigate to the previous page', () => { - expect(findRunnerPagination().find('[aria-disabled]').text()).toBe('Prev'); + expect(findRunnerPaginationPrev().attributes('aria-disabled')).toBe('true'); }); it('navigates to the next page', async () => { - const nextPageBtn = findRunnerPagination().find('a'); - expect(nextPageBtn.text()).toBe('Next'); - - await nextPageBtn.trigger('click'); + await findRunnerPaginationNext().trigger('click'); expect(mockRunnersQuery).toHaveBeenLastCalledWith({ sort: CREATED_DESC, diff --git a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js index 85cf7ea92df..46948af1f28 100644 --- a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js +++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js @@ -2,8 +2,16 @@ import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; +import { statusTokenConfig } from '~/runner/components/search_tokens/status_token_config'; import TagToken from '~/runner/components/search_tokens/tag_token.vue'; -import { PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE, PARAM_KEY_TAG } from '~/runner/constants'; +import { tagTokenConfig } from '~/runner/components/search_tokens/tag_token_config'; +import { typeTokenConfig } from '~/runner/components/search_tokens/type_token_config'; +import { + PARAM_KEY_STATUS, + PARAM_KEY_RUNNER_TYPE, + PARAM_KEY_TAG, + STATUS_ACTIVE, +} from '~/runner/constants'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -13,12 +21,12 @@ describe('RunnerList', () => { const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); const findSortOptions = () => wrapper.findAllComponents(GlDropdownItem); - const findActiveRunnersMessage = () => wrapper.findByTestId('active-runners-message'); + const findActiveRunnersMessage = () => wrapper.findByTestId('runner-count'); const mockDefaultSort = 'CREATED_DESC'; const mockOtherSort = 'CONTACTED_DESC'; const mockFilters = [ - { type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } }, + { type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }, { type: 'filtered-search-term', value: { data: '' } }, ]; const mockActiveRunnersCount = 2; @@ -28,13 +36,16 @@ describe('RunnerList', () => { shallowMount(RunnerFilteredSearchBar, { propsData: { namespace: 'runners', + tokens: [], value: { filters: [], sort: mockDefaultSort, }, - activeRunnersCount: mockActiveRunnersCount, ...props, }, + slots: { + 'runner-count': `Runners currently online: ${mockActiveRunnersCount}`, + }, stubs: { FilteredSearch, GlFilteredSearch, @@ -64,12 +75,6 @@ describe('RunnerList', () => { ); }); - it('Displays a large active runner count', () => { - createComponent({ props: { activeRunnersCount: 2000 } }); - - expect(findActiveRunnersMessage().text()).toBe('Runners currently online: 2,000'); - }); - it('sets sorting options', () => { const SORT_OPTIONS_COUNT = 2; @@ -78,7 +83,13 @@ describe('RunnerList', () => { expect(findSortOptions().at(1).text()).toBe('Last contact'); }); - it('sets tokens', () => { + it('sets tokens to the filtered search', () => { + createComponent({ + props: { + tokens: [statusTokenConfig, typeTokenConfig, tagTokenConfig], + }, + }); + expect(findFilteredSearch().props('tokens')).toEqual([ expect.objectContaining({ type: PARAM_KEY_STATUS, diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js index 5fff3581e39..344d1e5c150 100644 --- a/spec/frontend/runner/components/runner_list_spec.js +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -56,7 +56,7 @@ describe('RunnerList', () => { }); it('Displays a list of runners', () => { - expect(findRows()).toHaveLength(3); + expect(findRows()).toHaveLength(4); expect(findSkeletonLoader().exists()).toBe(false); }); diff --git a/spec/frontend/runner/components/runner_update_form_spec.js b/spec/frontend/runner/components/runner_update_form_spec.js index 15029d7a911..0e0844a785b 100644 --- a/spec/frontend/runner/components/runner_update_form_spec.js +++ b/spec/frontend/runner/components/runner_update_form_spec.js @@ -54,7 +54,7 @@ describe('RunnerUpdateForm', () => { ? ACCESS_LEVEL_REF_PROTECTED : ACCESS_LEVEL_NOT_PROTECTED, runUntagged: findRunUntaggedCheckbox().element.checked, - locked: findLockedCheckbox().element.checked, + locked: findLockedCheckbox().element?.checked || false, ipAddress: findIpInput().element.value, maximumTimeout: findMaxJobTimeoutInput().element.value || null, tagList: findTagsInput().element.value.split(',').filter(Boolean), @@ -153,15 +153,15 @@ describe('RunnerUpdateForm', () => { }); it.each` - runnerType | attrDisabled | outcome - ${INSTANCE_TYPE} | ${'disabled'} | ${'disabled'} - ${GROUP_TYPE} | ${'disabled'} | ${'disabled'} - ${PROJECT_TYPE} | ${undefined} | ${'enabled'} - `(`When runner is $runnerType, locked field is $outcome`, ({ runnerType, attrDisabled }) => { + runnerType | exists | outcome + ${INSTANCE_TYPE} | ${false} | ${'hidden'} + ${GROUP_TYPE} | ${false} | ${'hidden'} + ${PROJECT_TYPE} | ${true} | ${'shown'} + `(`When runner is $runnerType, locked field is $outcome`, ({ runnerType, exists }) => { const runner = { ...mockRunner, runnerType }; createComponent({ props: { runner } }); - expect(findLockedCheckbox().attributes('disabled')).toBe(attrDisabled); + expect(findLockedCheckbox().exists()).toBe(exists); }); describe('On submit, runner gets updated', () => { diff --git a/spec/frontend/runner/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js index 6a0863e92b4..e80da40e3bd 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -1,26 +1,85 @@ -import { shallowMount } from '@vue/test-utils'; +import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import { updateHistory } from '~/lib/utils/url_utility'; + +import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; +import RunnerList from '~/runner/components/runner_list.vue'; import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue'; +import RunnerPagination from '~/runner/components/runner_pagination.vue'; import RunnerTypeHelp from '~/runner/components/runner_type_help.vue'; + +import { + CREATED_ASC, + CREATED_DESC, + DEFAULT_SORT, + INSTANCE_TYPE, + PARAM_KEY_STATUS, + PARAM_KEY_RUNNER_TYPE, + STATUS_ACTIVE, + RUNNER_PAGE_SIZE, +} from '~/runner/constants'; +import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql'; import GroupRunnersApp from '~/runner/group_runners/group_runners_app.vue'; +import { captureException } from '~/runner/sentry_utils'; +import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import { groupRunnersData, groupRunnersDataPaginated } from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); +const mockGroupFullPath = 'group1'; const mockRegistrationToken = 'AABBCC'; +const mockRunners = groupRunnersData.data.group.runners.nodes; +const mockGroupRunnersLimitedCount = mockRunners.length; + +jest.mock('~/flash'); +jest.mock('~/runner/sentry_utils'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + updateHistory: jest.fn(), +})); describe('GroupRunnersApp', () => { let wrapper; + let mockGroupRunnersQuery; const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp); const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp); + const findRunnerList = () => wrapper.findComponent(RunnerList); + const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); + const findRunnerPaginationPrev = () => + findRunnerPagination().findByLabelText('Go to previous page'); + const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page'); + const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); + const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); + + const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { + const handlers = [[getGroupRunnersQuery, mockGroupRunnersQuery]]; - const createComponent = ({ mountFn = shallowMount } = {}) => { wrapper = mountFn(GroupRunnersApp, { + localVue, + apolloProvider: createMockApollo(handlers), propsData: { registrationToken: mockRegistrationToken, + groupFullPath: mockGroupFullPath, + groupRunnersLimitedCount: mockGroupRunnersLimitedCount, + ...props, }, }); }; - beforeEach(() => { + beforeEach(async () => { + setWindowLocation(`/groups/${mockGroupFullPath}/-/runners`); + + mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersData); + createComponent(); + await waitForPromises(); }); it('shows the runner type help', () => { @@ -28,7 +87,179 @@ describe('GroupRunnersApp', () => { }); it('shows the runner setup instructions', () => { - expect(findRunnerManualSetupHelp().exists()).toBe(true); expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken); }); + + it('shows the runners list', () => { + expect(findRunnerList().props('runners')).toEqual(groupRunnersData.data.group.runners.nodes); + }); + + it('requests the runners with group path and no other filters', () => { + expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ + groupFullPath: mockGroupFullPath, + status: undefined, + type: undefined, + sort: DEFAULT_SORT, + first: RUNNER_PAGE_SIZE, + }); + }); + + it('sets tokens in the filtered search', () => { + createComponent({ mountFn: mount }); + + expect(findFilteredSearch().props('tokens')).toEqual([ + expect.objectContaining({ + type: PARAM_KEY_STATUS, + options: expect.any(Array), + }), + expect.objectContaining({ + type: PARAM_KEY_RUNNER_TYPE, + options: expect.any(Array), + }), + ]); + }); + + describe('shows the active runner count', () => { + it('with a regular value', () => { + createComponent({ mountFn: mount }); + + expect(findRunnerFilteredSearchBar().text()).toMatch( + `Runners in this group: ${mockGroupRunnersLimitedCount}`, + ); + }); + + it('at the limit', () => { + createComponent({ props: { groupRunnersLimitedCount: 1000 }, mountFn: mount }); + + expect(findRunnerFilteredSearchBar().text()).toMatch(`Runners in this group: 1,000`); + }); + + it('over the limit', () => { + createComponent({ props: { groupRunnersLimitedCount: 1001 }, mountFn: mount }); + + expect(findRunnerFilteredSearchBar().text()).toMatch(`Runners in this group: 1,000+`); + }); + }); + + describe('when a filter is preselected', () => { + beforeEach(async () => { + setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`); + + createComponent(); + await waitForPromises(); + }); + + it('sets the filters in the search bar', () => { + expect(findRunnerFilteredSearchBar().props('value')).toEqual({ + filters: [ + { type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }, + { type: 'runner_type', value: { data: INSTANCE_TYPE, operator: '=' } }, + ], + sort: 'CREATED_DESC', + pagination: { page: 1 }, + }); + }); + + it('requests the runners with filter parameters', () => { + expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ + groupFullPath: mockGroupFullPath, + status: STATUS_ACTIVE, + type: INSTANCE_TYPE, + sort: DEFAULT_SORT, + first: RUNNER_PAGE_SIZE, + }); + }); + }); + + describe('when a filter is selected by the user', () => { + beforeEach(() => { + findRunnerFilteredSearchBar().vm.$emit('input', { + filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }], + sort: CREATED_ASC, + }); + }); + + it('updates the browser url', () => { + expect(updateHistory).toHaveBeenLastCalledWith({ + title: expect.any(String), + url: 'http://test.host/groups/group1/-/runners?status[]=ACTIVE&sort=CREATED_ASC', + }); + }); + + it('requests the runners with filters', () => { + expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ + groupFullPath: mockGroupFullPath, + status: STATUS_ACTIVE, + sort: CREATED_ASC, + first: RUNNER_PAGE_SIZE, + }); + }); + }); + + it('when runners have not loaded, shows a loading state', () => { + createComponent(); + expect(findRunnerList().props('loading')).toBe(true); + }); + + describe('when no runners are found', () => { + beforeEach(async () => { + mockGroupRunnersQuery = jest.fn().mockResolvedValue({ + data: { + group: { + runners: { nodes: [] }, + }, + }, + }); + createComponent(); + }); + + it('shows a message for no results', async () => { + expect(wrapper.text()).toContain('No runners found'); + }); + }); + + describe('when runners query fails', () => { + beforeEach(() => { + mockGroupRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!')); + createComponent(); + }); + + it('error is shown to the user', async () => { + expect(createFlash).toHaveBeenCalledTimes(1); + }); + + it('error is reported to sentry', async () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error('Network error: Error!'), + component: 'GroupRunnersApp', + }); + }); + }); + + describe('Pagination', () => { + beforeEach(() => { + mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersDataPaginated); + + createComponent({ mountFn: mount }); + }); + + it('more pages can be selected', () => { + expect(findRunnerPagination().text()).toMatchInterpolatedText('Prev Next'); + }); + + it('cannot navigate to the previous page', () => { + expect(findRunnerPaginationPrev().attributes('aria-disabled')).toBe('true'); + }); + + it('navigates to the next page', async () => { + await findRunnerPaginationNext().trigger('click'); + + expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ + groupFullPath: mockGroupFullPath, + sort: CREATED_DESC, + first: RUNNER_PAGE_SIZE, + after: groupRunnersDataPaginated.data.group.runners.pageInfo.endCursor, + }); + }); + }); }); diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js index 8f551feca6e..c90b9a4c426 100644 --- a/spec/frontend/runner/mock_data.js +++ b/spec/frontend/runner/mock_data.js @@ -1,6 +1,14 @@ +const runnerFixture = (filename) => getJSONFixture(`graphql/runner/${filename}`); + // Fixtures generated by: spec/frontend/fixtures/runner.rb -export const runnersData = getJSONFixture('graphql/runner/get_runners.query.graphql.json'); -export const runnersDataPaginated = getJSONFixture( - 'graphql/runner/get_runners.query.graphql.paginated.json', + +// Admin queries +export const runnersData = runnerFixture('get_runners.query.graphql.json'); +export const runnersDataPaginated = runnerFixture('get_runners.query.graphql.paginated.json'); +export const runnerData = runnerFixture('get_runner.query.graphql.json'); + +// Group queries +export const groupRunnersData = runnerFixture('get_group_runners.query.graphql.json'); +export const groupRunnersDataPaginated = runnerFixture( + 'get_group_runners.query.graphql.paginated.json', ); -export const runnerData = getJSONFixture('graphql/runner/get_runner.query.graphql.json'); diff --git a/spec/frontend/search/highlight_blob_search_result_spec.js b/spec/frontend/search/highlight_blob_search_result_spec.js index 6908bcbd283..9fa3bfc1f9a 100644 --- a/spec/frontend/search/highlight_blob_search_result_spec.js +++ b/spec/frontend/search/highlight_blob_search_result_spec.js @@ -9,6 +9,6 @@ describe('search/highlight_blob_search_result', () => { it('highlights lines with search term occurrence', () => { setHighlightClass(searchKeyword); - expect(document.querySelectorAll('.blob-result .hll').length).toBe(4); + expect(document.querySelectorAll('.js-blob-result .hll').length).toBe(4); }); }); diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js index 9f8c83f2873..b50248bb295 100644 --- a/spec/frontend/search/store/actions_spec.js +++ b/spec/frontend/search/store/actions_spec.js @@ -142,7 +142,13 @@ describe('Global Search Store Actions', () => { actions.fetchProjects({ commit: mockCommit, state }); expect(Api.groupProjects).not.toHaveBeenCalled(); - expect(Api.projects).toHaveBeenCalled(); + expect(Api.projects).toHaveBeenCalledWith( + state.query.search, + { + order_by: 'similarity', + }, + expect.any(Function), + ); }); }); }); diff --git a/spec/frontend/search/store/utils_spec.js b/spec/frontend/search/store/utils_spec.js index cd7f7dc3b5f..bcdad9f89dd 100644 --- a/spec/frontend/search/store/utils_spec.js +++ b/spec/frontend/search/store/utils_spec.js @@ -14,7 +14,7 @@ const CURRENT_TIME = new Date().getTime(); useLocalStorageSpy(); jest.mock('~/lib/utils/accessor', () => ({ - isLocalStorageAccessSafe: jest.fn().mockReturnValue(true), + canUseLocalStorage: jest.fn().mockReturnValue(true), })); describe('Global Search Store Utils', () => { diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/shortcuts_spec.js index fc5eeee9687..455db325066 100644 --- a/spec/frontend/shortcuts_spec.js +++ b/spec/frontend/shortcuts_spec.js @@ -70,8 +70,7 @@ describe('Shortcuts', () => { const mdShortcuts = $(this).data('md-shortcuts'); // jQuery.map() automatically unwraps arrays, so we - // have to double wrap the array to counteract this: - // https://stackoverflow.com/a/4875669/1063392 + // have to double wrap the array to counteract this return mdShortcuts ? [mdShortcuts] : undefined; }) .get(); diff --git a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js index 8504684d23a..39f63b2a9f4 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js @@ -206,7 +206,7 @@ describe('Sidebar assignees widget', () => { status: null, }, ], - id: 1, + id: 'gid://gitlab/Issue/1', }, ], ]); diff --git a/spec/frontend/sidebar/components/participants/sidebar_participants_widget_spec.js b/spec/frontend/sidebar/components/participants/sidebar_participants_widget_spec.js index 57b9a10b23e..859e63b3df6 100644 --- a/spec/frontend/sidebar/components/participants/sidebar_participants_widget_spec.js +++ b/spec/frontend/sidebar/components/participants/sidebar_participants_widget_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -45,6 +45,14 @@ describe('Sidebar Participants Widget', () => { expect(findParticipants().props('loading')).toBe(true); }); + it('emits toggleSidebar event when participants child component emits toggleSidebar', async () => { + createComponent(); + findParticipants().vm.$emit('toggleSidebar'); + + await nextTick(); + expect(wrapper.emitted('toggleSidebar')).toEqual([[]]); + }); + describe('when participants are loaded', () => { beforeEach(() => { createComponent({ diff --git a/spec/frontend/sidebar/sidebar_labels_spec.js b/spec/frontend/sidebar/sidebar_labels_spec.js index ab08a1e65e2..7455f684380 100644 --- a/spec/frontend/sidebar/sidebar_labels_spec.js +++ b/spec/frontend/sidebar/sidebar_labels_spec.js @@ -156,7 +156,7 @@ describe('sidebar labels', () => { variables: { input: { iid: defaultProps.iid, - labelIds: [toLabelGid(27), toLabelGid(28), toLabelGid(29), toLabelGid(40)], + labelIds: [toLabelGid(29), toLabelGid(28), toLabelGid(27), toLabelGid(40)], operationMode: MutationOperationMode.Replace, projectPath: defaultProps.projectPath, }, diff --git a/spec/frontend/sidebar/sidebar_mediator_spec.js b/spec/frontend/sidebar/sidebar_mediator_spec.js index 019ded87093..cb84c142d55 100644 --- a/spec/frontend/sidebar/sidebar_mediator_spec.js +++ b/spec/frontend/sidebar/sidebar_mediator_spec.js @@ -63,8 +63,6 @@ describe('Sidebar mediator', () => { expect(mediator.store.assignees).toEqual(mockData.assignees); expect(mediator.store.humanTimeEstimate).toEqual(mockData.human_time_estimate); expect(mediator.store.humanTotalTimeSpent).toEqual(mockData.human_total_time_spent); - expect(mediator.store.participants).toEqual(mockData.participants); - expect(mediator.store.subscribed).toEqual(mockData.subscribed); expect(mediator.store.timeEstimate).toEqual(mockData.time_estimate); expect(mediator.store.totalTimeSpent).toEqual(mockData.total_time_spent); }); @@ -117,19 +115,4 @@ describe('Sidebar mediator', () => { urlSpy.mockRestore(); }); }); - - it('toggle subscription', () => { - mediator.store.setSubscribedState(false); - mock.onPost(mediatorMockData.toggleSubscriptionEndpoint).reply(200, {}); - const spy = jest - .spyOn(mediator.service, 'toggleSubscription') - .mockReturnValue(Promise.resolve()); - - return mediator.toggleSubscription().then(() => { - expect(spy).toHaveBeenCalled(); - expect(mediator.store.subscribed).toEqual(true); - - spy.mockRestore(); - }); - }); }); diff --git a/spec/frontend/sidebar/sidebar_store_spec.js b/spec/frontend/sidebar/sidebar_store_spec.js index 7b73dc868b7..3930dabfcfa 100644 --- a/spec/frontend/sidebar/sidebar_store_spec.js +++ b/spec/frontend/sidebar/sidebar_store_spec.js @@ -16,17 +16,6 @@ const ANOTHER_ASSINEE = { avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', }; -const PARTICIPANT = { - id: 1, - state: 'active', - username: 'marcene', - name: 'Allie Will', - web_url: 'foo.com', - avatar_url: 'gravatar.com/avatar/xxx', -}; - -const PARTICIPANT_LIST = [PARTICIPANT, { ...PARTICIPANT, id: 2 }, { ...PARTICIPANT, id: 3 }]; - describe('Sidebar store', () => { let testContext; @@ -113,28 +102,6 @@ describe('Sidebar store', () => { expect(testContext.store.changing).toBe(true); }); - it('sets participants data', () => { - expect(testContext.store.participants.length).toEqual(0); - - testContext.store.setParticipantsData({ - participants: PARTICIPANT_LIST, - }); - - expect(testContext.store.isFetching.participants).toEqual(false); - expect(testContext.store.participants.length).toEqual(PARTICIPANT_LIST.length); - }); - - it('sets subcriptions data', () => { - expect(testContext.store.subscribed).toEqual(null); - - testContext.store.setSubscriptionsData({ - subscribed: true, - }); - - expect(testContext.store.isFetching.subscriptions).toEqual(false); - expect(testContext.store.subscribed).toEqual(true); - }); - it('set assigned data', () => { const users = { assignees: UsersMockHelper.createNumberRandomUsers(3), @@ -147,11 +114,11 @@ describe('Sidebar store', () => { }); it('sets fetching state', () => { - expect(testContext.store.isFetching.participants).toEqual(true); + expect(testContext.store.isFetching.assignees).toEqual(true); - testContext.store.setFetchingState('participants', false); + testContext.store.setFetchingState('assignees', false); - expect(testContext.store.isFetching.participants).toEqual(false); + expect(testContext.store.isFetching.assignees).toEqual(false); }); it('sets loading state', () => { diff --git a/spec/frontend/sidebar/track_invite_members_spec.js b/spec/frontend/sidebar/track_invite_members_spec.js index 6c96e4cfc76..5946e3320c4 100644 --- a/spec/frontend/sidebar/track_invite_members_spec.js +++ b/spec/frontend/sidebar/track_invite_members_spec.js @@ -10,7 +10,7 @@ describe('Track user dropdown open', () => { document.body.innerHTML = ` <div id="dummy-wrapper-element"> <div class="js-sidebar-assignee-dropdown"> - <div class="js-invite-members-track" data-track-event="_track_event_" data-track-label="_track_label_"> + <div class="js-invite-members-track" data-track-action="_track_event_" data-track-label="_track_label_"> </div> </div> </div> diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap index 22e206bb483..40bc6fe6aa5 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap @@ -28,6 +28,7 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = data-uploads-path="" > <markdown-header-stub + data-testid="markdownHeader" linecontent="" suggestionstartindex="0" /> diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js index a17efdd61a9..21fed51ff10 100644 --- a/spec/frontend/tracking_spec.js +++ b/spec/frontend/tracking_spec.js @@ -1,10 +1,15 @@ import { setHTMLFixture } from 'helpers/fixtures'; +import { TEST_HOST } from 'helpers/test_constants'; import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; -import { getExperimentData } from '~/experimentation/utils'; +import { getExperimentData, getAllExperimentContexts } from '~/experimentation/utils'; import Tracking, { initUserTracking, initDefaultTrackers } from '~/tracking'; +import { REFERRER_TTL, URLS_CACHE_STORAGE_KEY } from '~/tracking/constants'; import getStandardContext from '~/tracking/get_standard_context'; -jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn() })); +jest.mock('~/experimentation/utils', () => ({ + getExperimentData: jest.fn(), + getAllExperimentContexts: jest.fn(), +})); describe('Tracking', () => { let standardContext; @@ -12,9 +17,11 @@ describe('Tracking', () => { let bindDocumentSpy; let trackLoadEventsSpy; let enableFormTracking; + let setAnonymousUrlsSpy; beforeAll(() => { window.gl = window.gl || {}; + window.gl.snowplowUrls = {}; window.gl.snowplowStandardContext = { schema: 'iglu:com.gitlab/gitlab_standard', data: { @@ -29,6 +36,7 @@ describe('Tracking', () => { beforeEach(() => { getExperimentData.mockReturnValue(undefined); + getAllExperimentContexts.mockReturnValue([]); window.snowplow = window.snowplow || (() => {}); window.snowplowOptions = { @@ -70,6 +78,7 @@ describe('Tracking', () => { enableFormTracking = jest .spyOn(Tracking, 'enableFormTracking') .mockImplementation(() => null); + setAnonymousUrlsSpy = jest.spyOn(Tracking, 'setAnonymousUrls').mockImplementation(() => null); }); it('should activate features based on what has been enabled', () => { @@ -100,6 +109,36 @@ describe('Tracking', () => { initDefaultTrackers(); expect(trackLoadEventsSpy).toHaveBeenCalled(); }); + + it('calls the anonymized URLs method', () => { + initDefaultTrackers(); + expect(setAnonymousUrlsSpy).toHaveBeenCalled(); + }); + + describe('when there are experiment contexts', () => { + const experimentContexts = [ + { + schema: TRACKING_CONTEXT_SCHEMA, + data: { experiment: 'experiment1', variant: 'control' }, + }, + { + schema: TRACKING_CONTEXT_SCHEMA, + data: { experiment: 'experiment_two', variant: 'candidate' }, + }, + ]; + + beforeEach(() => { + getAllExperimentContexts.mockReturnValue(experimentContexts); + }); + + it('includes those contexts alongside the standard context', () => { + initDefaultTrackers(); + expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [ + standardContext, + ...experimentContexts, + ]); + }); + }); }); describe('.event', () => { @@ -266,6 +305,110 @@ describe('Tracking', () => { }); }); + describe('.setAnonymousUrls', () => { + afterEach(() => { + window.gl.snowplowPseudonymizedPageUrl = ''; + localStorage.removeItem(URLS_CACHE_STORAGE_KEY); + }); + + it('does nothing if URLs are not provided', () => { + Tracking.setAnonymousUrls(); + + expect(snowplowSpy).not.toHaveBeenCalled(); + expect(localStorage.getItem(URLS_CACHE_STORAGE_KEY)).toBe(null); + }); + + it('sets the page URL when provided and populates the cache', () => { + window.gl.snowplowPseudonymizedPageUrl = TEST_HOST; + + Tracking.setAnonymousUrls(); + + expect(snowplowSpy).toHaveBeenCalledWith('setCustomUrl', TEST_HOST); + expect(JSON.parse(localStorage.getItem(URLS_CACHE_STORAGE_KEY))[0]).toStrictEqual({ + url: TEST_HOST, + referrer: '', + originalUrl: window.location.href, + timestamp: Date.now(), + }); + }); + + it('appends the hash/fragment to the pseudonymized URL', () => { + const hash = 'first-heading'; + window.gl.snowplowPseudonymizedPageUrl = TEST_HOST; + window.location.hash = hash; + + Tracking.setAnonymousUrls(); + + expect(snowplowSpy).toHaveBeenCalledWith('setCustomUrl', `${TEST_HOST}#${hash}`); + }); + + it('does not set the referrer URL by default', () => { + window.gl.snowplowPseudonymizedPageUrl = TEST_HOST; + + Tracking.setAnonymousUrls(); + + expect(snowplowSpy).not.toHaveBeenCalledWith('setReferrerUrl', expect.any(String)); + }); + + describe('with referrers cache', () => { + const testUrl = '/namespace:1/project:2/-/merge_requests/5'; + const testOriginalUrl = '/my-namespace/my-project/-/merge_requests/'; + const setUrlsCache = (data) => + localStorage.setItem(URLS_CACHE_STORAGE_KEY, JSON.stringify(data)); + + beforeEach(() => { + window.gl.snowplowPseudonymizedPageUrl = TEST_HOST; + Object.defineProperty(document, 'referrer', { value: '', configurable: true }); + }); + + it('does nothing if a referrer can not be found', () => { + setUrlsCache([ + { + url: testUrl, + originalUrl: TEST_HOST, + timestamp: Date.now(), + }, + ]); + + Tracking.setAnonymousUrls(); + + expect(snowplowSpy).not.toHaveBeenCalledWith('setReferrerUrl', expect.any(String)); + }); + + it('sets referrer URL from the page URL found in cache', () => { + Object.defineProperty(document, 'referrer', { value: testOriginalUrl }); + setUrlsCache([ + { + url: testUrl, + originalUrl: testOriginalUrl, + timestamp: Date.now(), + }, + ]); + + Tracking.setAnonymousUrls(); + + expect(snowplowSpy).toHaveBeenCalledWith('setReferrerUrl', testUrl); + }); + + it('ignores and removes old entries from the cache', () => { + const oldTimestamp = Date.now() - (REFERRER_TTL + 1); + Object.defineProperty(document, 'referrer', { value: testOriginalUrl }); + setUrlsCache([ + { + url: testUrl, + originalUrl: testOriginalUrl, + timestamp: oldTimestamp, + }, + ]); + + Tracking.setAnonymousUrls(); + + expect(snowplowSpy).not.toHaveBeenCalledWith('setReferrerUrl', testUrl); + expect(localStorage.getItem(URLS_CACHE_STORAGE_KEY)).not.toContain(oldTimestamp); + }); + }); + }); + describe.each` term ${'event'} @@ -349,7 +492,7 @@ describe('Tracking', () => { it('includes experiment data if linked to an experiment', () => { const mockExperimentData = { variant: 'candidate', - experiment: 'repo_integrations_link', + experiment: 'example', key: '2bff73f6bb8cc11156c50a8ba66b9b8b', }; getExperimentData.mockReturnValue(mockExperimentData); diff --git a/spec/frontend/vue_mr_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap b/spec/frontend/vue_mr_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap new file mode 100644 index 00000000000..a6c36764c41 --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`New ready to merge state component renders permission text if canMerge (false) is false 1`] = ` +<div + class="mr-widget-body media" +> + <status-icon-stub + status="success" + /> + + <p + class="media-body gl-m-0! gl-font-weight-bold" + > + + Ready to merge by members who can write to the target branch. + + </p> +</div> +`; + +exports[`New ready to merge state component renders permission text if canMerge (true) is false 1`] = ` +<div + class="mr-widget-body media" +> + <status-icon-stub + status="success" + /> + + <p + class="media-body gl-m-0! gl-font-weight-bold" + > + + Ready to merge! + + </p> +</div> +`; diff --git a/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js b/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js new file mode 100644 index 00000000000..bdad0bada5f --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js @@ -0,0 +1,49 @@ +import { shallowMount } from '@vue/test-utils'; +import MergeChecksFailed from '~/vue_merge_request_widget/components/states/merge_checks_failed.vue'; + +let wrapper; + +function factory(propsData = {}) { + wrapper = shallowMount(MergeChecksFailed, { + propsData, + }); +} + +describe('Merge request widget merge checks failed state component', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + mrState | displayText + ${{ isPipelineFailed: true }} | ${'pipelineFailed'} + ${{ approvals: true, isApproved: false }} | ${'approvalNeeded'} + ${{ hasMergeableDiscussionsState: true }} | ${'unresolvedDiscussions'} + `('display $displayText text for $mrState', ({ mrState, displayText }) => { + factory({ mr: mrState }); + + expect(wrapper.text()).toContain(MergeChecksFailed.i18n[displayText]); + }); + + describe('unresolved discussions', () => { + it('renders jump to button', () => { + factory({ mr: { hasMergeableDiscussionsState: true } }); + + expect(wrapper.find('[data-testid="jumpToUnresolved"]').exists()).toBe(true); + }); + + it('renders resolve thread button', () => { + factory({ + mr: { + hasMergeableDiscussionsState: true, + createIssueToResolveDiscussionsPath: 'https://gitlab.com', + }, + }); + + expect(wrapper.find('[data-testid="resolveIssue"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="resolveIssue"]').attributes('href')).toBe( + 'https://gitlab.com', + ); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js index c6bfca4516f..e2d79c61b9b 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js @@ -45,7 +45,7 @@ describe('UnresolvedDiscussions', () => { expect(wrapper.element.innerText).toContain(`Merge blocked: all threads must be resolved.`); expect(wrapper.element.innerText).toContain('Jump to first unresolved thread'); - expect(wrapper.element.innerText).toContain('Resolve all threads in new issue'); + expect(wrapper.element.innerText).toContain('Create issue to resolve all threads'); expect(wrapper.element.querySelector('.js-create-issue').getAttribute('href')).toEqual( TEST_HOST, ); @@ -57,7 +57,7 @@ describe('UnresolvedDiscussions', () => { expect(wrapper.element.innerText).toContain(`Merge blocked: all threads must be resolved.`); expect(wrapper.element.innerText).toContain('Jump to first unresolved thread'); - expect(wrapper.element.innerText).not.toContain('Resolve all threads in new issue'); + expect(wrapper.element.innerText).not.toContain('Create issue to resolve all threads'); expect(wrapper.element.querySelector('.js-create-issue')).toEqual(null); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js index 0609086997b..61e44140efc 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js @@ -64,7 +64,7 @@ describe('Wip', () => { expect(vm.isMakingRequest).toBeTruthy(); expect(eventHub.$emit).toHaveBeenCalledWith('UpdateWidgetData', mrObj); expect(createFlash).toHaveBeenCalledWith({ - message: 'The merge request can now be merged.', + message: 'Marked as ready. Merging is now allowed.', type: 'notice', }); done(); diff --git a/spec/frontend/vue_mr_widget/components/states/new_ready_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/new_ready_to_merge_spec.js new file mode 100644 index 00000000000..5ec9654a4af --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/states/new_ready_to_merge_spec.js @@ -0,0 +1,31 @@ +import { shallowMount } from '@vue/test-utils'; +import ReadyToMerge from '~/vue_merge_request_widget/components/states/new_ready_to_merge.vue'; + +let wrapper; + +function factory({ canMerge }) { + wrapper = shallowMount(ReadyToMerge, { + propsData: { + mr: {}, + }, + data() { + return { canMerge }; + }, + }); +} + +describe('New ready to merge state component', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + canMerge + ${true} + ${false} + `('renders permission text if canMerge ($canMerge) is false', ({ canMerge }) => { + factory({ canMerge }); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap index bab928318ce..c7758b0faef 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap @@ -3,9 +3,13 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` <gl-dropdown-stub category="primary" + clearalltext="Clear all" headertext="" hideheaderborder="true" + highlighteditemstitle="Selected" + highlighteditemstitleclass="gl-px-5" right="true" + showhighlighteditemstitle="true" size="medium" text="Clone" variant="info" diff --git a/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap index db174346729..7f655d67ae8 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap @@ -2,7 +2,7 @@ exports[`Code Block with default props renders correctly 1`] = ` <pre - class="code-block rounded" + class="code-block rounded code" > <code class="d-block" @@ -14,7 +14,7 @@ exports[`Code Block with default props renders correctly 1`] = ` exports[`Code Block with maxHeight set to "200px" renders correctly 1`] = ` <pre - class="code-block rounded" + class="code-block rounded code" style="max-height: 200px; overflow-y: auto;" > <code diff --git a/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap index f4f9cc288f9..87eaabf4e98 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap @@ -9,7 +9,6 @@ exports[`MemoryGraph Render chart should draw container with chart 1`] = ` data="Nov 12 2019 19:17:33,2.87,Nov 12 2019 19:18:33,2.78,Nov 12 2019 19:19:33,2.78,Nov 12 2019 19:20:33,3.01" height="25" tooltiplabel="MB" - variant="gray900" /> </div> `; 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 index c4f351eb58d..f2ff12b2acd 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap @@ -3,9 +3,13 @@ exports[`SplitButton renders actionItems 1`] = ` <gl-dropdown-stub category="primary" + clearalltext="Clear all" headertext="" hideheaderborder="true" + highlighteditemstitle="Selected" + highlighteditemstitleclass="gl-px-5" menu-class="" + showhighlighteditemstitle="true" size="medium" split="true" text="professor" diff --git a/spec/frontend/vue_shared/components/commit_spec.js b/spec/frontend/vue_shared/components/commit_spec.js index 6a31742141b..d91853e7b79 100644 --- a/spec/frontend/vue_shared/components/commit_spec.js +++ b/spec/frontend/vue_shared/components/commit_spec.js @@ -162,8 +162,6 @@ describe('Commit component', () => { expect(refEl.attributes('href')).toBe(props.commitRef.ref_url); - expect(refEl.attributes('title')).toBe(props.commitRef.name); - expect(findIcon('branch').exists()).toBe(true); }); }); @@ -195,8 +193,6 @@ describe('Commit component', () => { expect(refEl.attributes('href')).toBe(props.mergeRequestRef.path); - expect(refEl.attributes('title')).toBe(props.mergeRequestRef.title); - expect(findIcon('git-merge').exists()).toBe(true); }); }); diff --git a/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js b/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js new file mode 100644 index 00000000000..04f63b4bd45 --- /dev/null +++ b/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js @@ -0,0 +1,176 @@ +import { + GlSprintf, + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlSearchBoxByType, +} from '@gitlab/ui'; +import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import DiffStatsDropdown, { i18n } from '~/vue_shared/components/diff_stats_dropdown.vue'; + +jest.mock('fuzzaldrin-plus', () => ({ + filter: jest.fn().mockReturnValue([]), +})); + +const mockFiles = [ + { + added: 0, + href: '#a5cc2925ca8258af241be7e5b0381edf30266302', + icon: 'file-modified', + iconColor: '', + name: '', + path: '.gitignore', + removed: 3, + title: '.gitignore', + }, + { + added: 1, + href: '#fa288d1472d29beccb489a676f68739ad365fc47', + icon: 'file-modified', + iconColor: 'danger', + name: 'package-lock.json', + path: 'lock/file/path', + removed: 1, + }, +]; + +describe('Diff Stats Dropdown', () => { + let wrapper; + + const createComponent = ({ changed = 0, added = 0, deleted = 0, files = [] } = {}) => { + wrapper = shallowMountExtended(DiffStatsDropdown, { + propsData: { + changed, + added, + deleted, + files, + }, + stubs: { + GlSprintf, + GlDropdown, + }, + }); + }; + + const findChanged = () => wrapper.findComponent(GlDropdown); + const findChangedFiles = () => findChanged().findAllComponents(GlDropdownItem); + const findNoFilesText = () => findChanged().findComponent(GlDropdownText); + const findCollapsed = () => wrapper.findByTestId('diff-stats-additions-deletions-expanded'); + const findExpanded = () => wrapper.findByTestId('diff-stats-additions-deletions-collapsed'); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + + describe('file item', () => { + beforeEach(() => { + createComponent({ files: mockFiles }); + }); + + it('when no file name provided ', () => { + expect(findChangedFiles().at(0).text()).toContain(i18n.noFileNameAvailable); + }); + + it('when all file data is available', () => { + const fileData = findChangedFiles().at(1); + const fileText = findChangedFiles().at(1).text(); + expect(fileText).toContain(mockFiles[1].name); + expect(fileText).toContain(mockFiles[1].path); + expect(fileData.props()).toMatchObject({ + iconName: mockFiles[1].icon, + iconColor: mockFiles[1].iconColor, + }); + }); + + it('when no files changed', () => { + createComponent({ files: [] }); + expect(findNoFilesText().text()).toContain(i18n.noFilesFound); + }); + }); + + describe.each` + changed | added | deleted | expectedDropdownHeader | expectedAddedDeletedExpanded | expectedAddedDeletedCollapsed + ${0} | ${0} | ${0} | ${'0 changed files'} | ${'+0 -0'} | ${'with 0 additions and 0 deletions'} + ${2} | ${0} | ${2} | ${'2 changed files'} | ${'+0 -2'} | ${'with 0 additions and 2 deletions'} + ${2} | ${2} | ${0} | ${'2 changed files'} | ${'+2 -0'} | ${'with 2 additions and 0 deletions'} + ${2} | ${1} | ${1} | ${'2 changed files'} | ${'+1 -1'} | ${'with 1 addition and 1 deletion'} + ${1} | ${0} | ${1} | ${'1 changed file'} | ${'+0 -1'} | ${'with 0 additions and 1 deletion'} + ${1} | ${1} | ${0} | ${'1 changed file'} | ${'+1 -0'} | ${'with 1 addition and 0 deletions'} + ${4} | ${2} | ${2} | ${'4 changed files'} | ${'+2 -2'} | ${'with 2 additions and 2 deletions'} + `( + 'when there are $changed changed file(s), $added added and $deleted deleted file(s)', + ({ + changed, + added, + deleted, + expectedDropdownHeader, + expectedAddedDeletedExpanded, + expectedAddedDeletedCollapsed, + }) => { + beforeAll(() => { + createComponent({ changed, added, deleted }); + }); + + afterAll(() => { + wrapper.destroy(); + }); + + it(`dropdown header should be '${expectedDropdownHeader}'`, () => { + expect(findChanged().props('text')).toBe(expectedDropdownHeader); + }); + + it(`added and deleted count in expanded section should be '${expectedAddedDeletedExpanded}'`, () => { + expect(findExpanded().text()).toBe(expectedAddedDeletedExpanded); + }); + + it(`added and deleted count in collapsed section should be '${expectedAddedDeletedCollapsed}'`, () => { + expect(findCollapsed().text()).toBe(expectedAddedDeletedCollapsed); + }); + }, + ); + + describe('fuzzy file search', () => { + beforeEach(() => { + createComponent({ files: mockFiles }); + }); + + it('should call `fuzzaldrinPlus.filter` to search for files when the search query is NOT empty', async () => { + const searchStr = 'file name'; + findSearchBox().vm.$emit('input', searchStr); + await nextTick(); + expect(fuzzaldrinPlus.filter).toHaveBeenCalledWith(mockFiles, searchStr, { key: 'name' }); + }); + + it('should NOT call `fuzzaldrinPlus.filter` to search for files when the search query is empty', async () => { + const searchStr = ''; + findSearchBox().vm.$emit('input', searchStr); + await nextTick(); + expect(fuzzaldrinPlus.filter).not.toHaveBeenCalled(); + }); + }); + + describe('selecting file dropdown item', () => { + beforeEach(() => { + createComponent({ files: mockFiles }); + }); + + it('updates the URL ', () => { + findChangedFiles().at(0).vm.$emit('click'); + expect(window.location.hash).toBe(mockFiles[0].href); + findChangedFiles().at(1).vm.$emit('click'); + expect(window.location.hash).toBe(mockFiles[1].href); + }); + }); + + describe('on dropdown open', () => { + beforeEach(() => { + createComponent(); + }); + + it('should set the search input focus', () => { + wrapper.vm.$refs.search.focusInput = jest.fn(); + findChanged().vm.$emit('shown'); + + expect(wrapper.vm.$refs.search.focusInput).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js index 1b97011bf7f..d85b6e6d115 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js @@ -25,7 +25,7 @@ import { const mockStorageKey = 'recent-tokens'; function setLocalStorageAvailability(isAvailable) { - jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(isAvailable); + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(isAvailable); } describe('Filtered Search Utils', () => { @@ -309,7 +309,7 @@ describe('urlQueryToFilter', () => { { [FILTERED_SEARCH_TERM]: [{ value: 'my' }, { value: 'terms' }], }, - { filteredSearchTermKey: 'search', legacySpacesDecode: false }, + { filteredSearchTermKey: 'search' }, ], [ 'search=my terms&foo=bar&nop=xxx', diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js index 529844817d3..bfb593bf82d 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js @@ -11,7 +11,10 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { sortMilestonesByDueDate } from '~/milestones/milestone_utils'; -import { DEFAULT_MILESTONES } from '~/vue_shared/components/filtered_search_bar/constants'; +import { + DEFAULT_MILESTONES, + DEFAULT_MILESTONES_GRAPHQL, +} from '~/vue_shared/components/filtered_search_bar/constants'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; import { mockMilestoneToken, mockMilestones, mockRegularMilestone } from '../mock_data'; @@ -191,5 +194,22 @@ describe('MilestoneToken', () => { expect(suggestions.at(index).text()).toBe(milestone.text); }); }); + + describe('when getActiveMilestones is called and milestones is empty', () => { + beforeEach(() => { + wrapper = createComponent({ + active: true, + config: { ...mockMilestoneToken, defaultMilestones: DEFAULT_MILESTONES_GRAPHQL }, + }); + }); + + it('finds the correct value from the activeToken', () => { + DEFAULT_MILESTONES_GRAPHQL.forEach(({ value, title }) => { + const activeToken = wrapper.vm.getActiveMilestone([], value); + + expect(activeToken.title).toEqual(title); + }); + }); + }); }); }); diff --git a/spec/frontend/vue_shared/components/header_ci_component_spec.js b/spec/frontend/vue_shared/components/header_ci_component_spec.js index b54d120b55b..42f4439df51 100644 --- a/spec/frontend/vue_shared/components/header_ci_component_spec.js +++ b/spec/frontend/vue_shared/components/header_ci_component_spec.js @@ -16,8 +16,6 @@ describe('Header CI Component', () => { text: 'failed', details_path: 'path', }, - itemName: 'job', - itemId: 123, time: '2017-05-08T14:57:39.781Z', user: { web_url: 'path', @@ -55,17 +53,13 @@ describe('Header CI Component', () => { describe('render', () => { beforeEach(() => { - createComponent(); + createComponent({ itemName: 'Pipeline' }); }); it('should render status badge', () => { expect(findIconBadge().exists()).toBe(true); }); - it('should render item name and id', () => { - expect(findHeaderItemText().text()).toBe('job #123'); - }); - it('should render timeago date', () => { expect(findTimeAgo().exists()).toBe(true); }); @@ -83,9 +77,29 @@ describe('Header CI Component', () => { }); }); + describe('with item id', () => { + beforeEach(() => { + createComponent({ itemName: 'Pipeline', itemId: '123' }); + }); + + it('should render item name and id', () => { + expect(findHeaderItemText().text()).toBe('Pipeline #123'); + }); + }); + + describe('without item id', () => { + beforeEach(() => { + createComponent({ itemName: 'Job build_job' }); + }); + + it('should render item name', () => { + expect(findHeaderItemText().text()).toBe('Job build_job'); + }); + }); + describe('slot', () => { it('should render header action buttons', () => { - createComponent({}, { slots: { default: 'Test Actions' } }); + createComponent({ itemName: 'Job build_job' }, { slots: { default: 'Test Actions' } }); expect(findActionButtons().exists()).toBe(true); expect(findActionButtons().text()).toBe('Test Actions'); @@ -94,7 +108,7 @@ describe('Header CI Component', () => { describe('shouldRenderTriggeredLabel', () => { it('should render created keyword when the shouldRenderTriggeredLabel is false', () => { - createComponent({ shouldRenderTriggeredLabel: false }); + createComponent({ shouldRenderTriggeredLabel: false, itemName: 'Job build_job' }); expect(wrapper.text()).toContain('created'); expect(wrapper.text()).not.toContain('triggered'); diff --git a/spec/frontend/vue_shared/components/issuable/issuable_header_warnings_spec.js b/spec/frontend/vue_shared/components/issuable/issuable_header_warnings_spec.js index 573501233b9..ad8331afcff 100644 --- a/spec/frontend/vue_shared/components/issuable/issuable_header_warnings_spec.js +++ b/spec/frontend/vue_shared/components/issuable/issuable_header_warnings_spec.js @@ -1,5 +1,7 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { createStore as createMrStore } from '~/mr_notes/stores'; import createIssueStore from '~/notes/stores'; import IssuableHeaderWarnings from '~/vue_shared/components/issuable/issuable_header_warnings.vue'; @@ -12,52 +14,53 @@ localVue.use(Vuex); describe('IssuableHeaderWarnings', () => { let wrapper; - let store; - const findConfidentialIcon = () => wrapper.find('[data-testid="confidential"]'); - const findLockedIcon = () => wrapper.find('[data-testid="locked"]'); + const findConfidentialIcon = () => wrapper.findByTestId('confidential'); + const findLockedIcon = () => wrapper.findByTestId('locked'); + const findHiddenIcon = () => wrapper.findByTestId('hidden'); const renderTestMessage = (renders) => (renders ? 'renders' : 'does not render'); - const setLock = (locked) => { - store.getters.getNoteableData.discussion_locked = locked; - }; - - const setConfidential = (confidential) => { - store.getters.getNoteableData.confidential = confidential; - }; - - const createComponent = () => { - wrapper = shallowMount(IssuableHeaderWarnings, { store, localVue }); + const createComponent = ({ store, provide }) => { + wrapper = shallowMountExtended(IssuableHeaderWarnings, { + store, + localVue, + provide, + directives: { + GlTooltip: createMockDirective(), + }, + }); }; afterEach(() => { wrapper.destroy(); wrapper = null; - store = null; }); describe.each` issuableType ${ISSUABLE_TYPE_ISSUE} | ${ISSUABLE_TYPE_MR} `(`when issuableType=$issuableType`, ({ issuableType }) => { - beforeEach(() => { - store = issuableType === ISSUABLE_TYPE_ISSUE ? createIssueStore() : createMrStore(); - createComponent(); - }); - describe.each` - lockStatus | confidentialStatus - ${true} | ${true} - ${true} | ${false} - ${false} | ${true} - ${false} | ${false} + lockStatus | confidentialStatus | hiddenStatus + ${true} | ${true} | ${false} + ${true} | ${false} | ${false} + ${false} | ${true} | ${false} + ${false} | ${false} | ${false} + ${true} | ${true} | ${true} + ${true} | ${false} | ${true} + ${false} | ${true} | ${true} + ${false} | ${false} | ${true} `( - `when locked=$lockStatus and confidential=$confidentialStatus`, - ({ lockStatus, confidentialStatus }) => { + `when locked=$lockStatus, confidential=$confidentialStatus, and hidden=$hiddenStatus`, + ({ lockStatus, confidentialStatus, hiddenStatus }) => { + const store = issuableType === ISSUABLE_TYPE_ISSUE ? createIssueStore() : createMrStore(); + beforeEach(() => { - setLock(lockStatus); - setConfidential(confidentialStatus); + store.getters.getNoteableData.confidential = confidentialStatus; + store.getters.getNoteableData.discussion_locked = lockStatus; + + createComponent({ store, provide: { hidden: hiddenStatus } }); }); it(`${renderTestMessage(lockStatus)} the locked icon`, () => { @@ -67,6 +70,19 @@ describe('IssuableHeaderWarnings', () => { it(`${renderTestMessage(confidentialStatus)} the confidential icon`, () => { expect(findConfidentialIcon().exists()).toBe(confidentialStatus); }); + + it(`${renderTestMessage(confidentialStatus)} the hidden icon`, () => { + const hiddenIcon = findHiddenIcon(); + + expect(hiddenIcon.exists()).toBe(hiddenStatus); + + if (hiddenStatus) { + expect(hiddenIcon.attributes('title')).toBe( + 'This issue is hidden because its author has been banned', + ); + expect(getBinding(hiddenIcon.element, 'gl-tooltip')).not.toBeUndefined(); + } + }); }, ); }); diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js index 442032840e1..76e1a1162ad 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -32,7 +32,7 @@ describe('Markdown field component', () => { axiosMock.restore(); }); - function createSubject() { + function createSubject(lines = []) { // We actually mount a wrapper component so that we can force Vue to rerender classes in order to test a regression // caused by mixing Vanilla JS and Vue. subject = mount( @@ -60,6 +60,7 @@ describe('Markdown field component', () => { markdownPreviewPath, isSubmitting: false, textareaValue, + lines, }, }, ); @@ -243,4 +244,14 @@ describe('Markdown field component', () => { }); }); }); + + describe('suggestions', () => { + it('escapes new line characters', () => { + createSubject([{ rich_text: 'hello world\\n' }]); + + expect(subject.find('[data-testid="markdownHeader"]').props('lineContent')).toBe( + 'hello world%br', + ); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/registry/title_area_spec.js b/spec/frontend/vue_shared/components/registry/title_area_spec.js index fb0009ebb8d..75aa3bc7096 100644 --- a/spec/frontend/vue_shared/components/registry/title_area_spec.js +++ b/spec/frontend/vue_shared/components/registry/title_area_spec.js @@ -135,15 +135,16 @@ describe('title area', () => { }, }); }; + it('shows dynamic slots', async () => { mountComponent(); // we manually add a new slot to simulate dynamic slots being evaluated after the initial mount wrapper.vm.$slots[DYNAMIC_SLOT] = createDynamicSlot(); + // updating the slots like we do on line 141 does not cause the updated lifecycle-hook to be triggered + wrapper.vm.$forceUpdate(); await wrapper.vm.$nextTick(); - expect(findDynamicSlot().exists()).toBe(false); - await wrapper.vm.$nextTick(); expect(findDynamicSlot().exists()).toBe(true); }); @@ -160,10 +161,8 @@ describe('title area', () => { 'metadata-foo': wrapper.vm.$slots['metadata-foo'], }; - await wrapper.vm.$nextTick(); - expect(findDynamicSlot().exists()).toBe(false); - expect(findMetadataSlot('metadata-foo').exists()).toBe(true); - + // updating the slots like we do on line 159 does not cause the updated lifecycle-hook to be triggered + wrapper.vm.$forceUpdate(); await wrapper.vm.$nextTick(); expect(findSlotOrderElements().at(0).attributes('data-testid')).toBe(DYNAMIC_SLOT); diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js b/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js index 69db3ec7132..ad692a38e65 100644 --- a/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js +++ b/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js @@ -21,6 +21,7 @@ describe('RunnerAwsDeploymentsModal', () => { wrapper = shallowMount(RunnerAwsDeploymentsModal, { propsData: { modalId: 'runner-aws-deployments-modal', + imgSrc: '/assets/aws-cloud-formation.png', }, }); }; diff --git a/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap b/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap index ed085fb66dc..165caea2751 100644 --- a/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap +++ b/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap @@ -8,12 +8,25 @@ exports[`Settings Block renders the correct markup 1`] = ` class="settings-header" > <h4> - <div - data-testid="title-slot" - /> + <span + aria-controls="settings_content_3" + aria-expanded="false" + class="gl-cursor-pointer" + data-testid="section-title-button" + id="settings_label_2" + role="button" + tabindex="0" + > + <div + data-testid="title-slot" + /> + </span> </h4> <gl-button-stub + aria-controls="settings_content_3" + aria-expanded="false" + aria-label="Expand settings section" buttontextclasses="" category="primary" icon="" @@ -33,7 +46,11 @@ exports[`Settings Block renders the correct markup 1`] = ` </div> <div + aria-labelledby="settings_label_2" class="settings-content" + id="settings_content_3" + role="region" + tabindex="-1" > <div data-testid="default-slot" diff --git a/spec/frontend/vue_shared/components/settings/settings_block_spec.js b/spec/frontend/vue_shared/components/settings/settings_block_spec.js index be5a15631eb..528dfd89690 100644 --- a/spec/frontend/vue_shared/components/settings/settings_block_spec.js +++ b/spec/frontend/vue_shared/components/settings/settings_block_spec.js @@ -1,12 +1,12 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import component from '~/vue_shared/components/settings/settings_block.vue'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; describe('Settings Block', () => { let wrapper; const mountComponent = (propsData) => { - wrapper = shallowMount(component, { + wrapper = shallowMount(SettingsBlock, { propsData, slots: { title: '<div data-testid="title-slot"></div>', @@ -18,13 +18,25 @@ describe('Settings Block', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); const findDefaultSlot = () => wrapper.find('[data-testid="default-slot"]'); const findTitleSlot = () => wrapper.find('[data-testid="title-slot"]'); const findDescriptionSlot = () => wrapper.find('[data-testid="description-slot"]'); - const findExpandButton = () => wrapper.find(GlButton); + const findExpandButton = () => wrapper.findComponent(GlButton); + const findSectionTitleButton = () => wrapper.find('[data-testid="section-title-button"]'); + + const expectExpandedState = ({ expanded = true } = {}) => { + const settingsExpandButton = findExpandButton(); + + expect(wrapper.classes('expanded')).toBe(expanded); + expect(settingsExpandButton.text()).toBe( + expanded ? SettingsBlock.i18n.collapseText : SettingsBlock.i18n.expandText, + ); + expect(settingsExpandButton.attributes('aria-label')).toBe( + expanded ? SettingsBlock.i18n.collapseAriaLabel : SettingsBlock.i18n.expandAriaLabel, + ); + }; it('renders the correct markup', () => { mountComponent(); @@ -75,33 +87,41 @@ describe('Settings Block', () => { it('is collapsed by default', () => { mountComponent(); - expect(wrapper.classes('expanded')).toBe(false); + expectExpandedState({ expanded: false }); }); it('adds expanded class when the expand button is clicked', async () => { mountComponent(); - expect(wrapper.classes('expanded')).toBe(false); - expect(findExpandButton().text()).toBe('Expand'); - await findExpandButton().vm.$emit('click'); - expect(wrapper.classes('expanded')).toBe(true); - expect(findExpandButton().text()).toBe('Collapse'); + expectExpandedState({ expanded: true }); }); - it('is expanded when `defaultExpanded` is true no matter what', async () => { - mountComponent({ defaultExpanded: true }); + it('adds expanded class when the section title is clicked', async () => { + mountComponent(); - expect(wrapper.classes('expanded')).toBe(true); + await findSectionTitleButton().trigger('click'); - await findExpandButton().vm.$emit('click'); + expectExpandedState({ expanded: true }); + }); - expect(wrapper.classes('expanded')).toBe(true); + describe('when `collapsible` is `false`', () => { + beforeEach(() => { + mountComponent({ collapsible: false }); + }); - await findExpandButton().vm.$emit('click'); + it('does not render clickable section title', () => { + expect(findSectionTitleButton().exists()).toBe(false); + }); + + it('contains expanded class', () => { + expect(wrapper.classes('expanded')).toBe(true); + }); - expect(wrapper.classes('expanded')).toBe(true); + it('does not render expand toggle button', () => { + expect(findExpandButton().exists()).toBe(false); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js index a1942e59571..e39e8794fdd 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js @@ -124,7 +124,7 @@ describe('DropdownContentsLabelsView', () => { }); it('returns false when provided `label` param is not one of the selected labels', () => { - expect(wrapper.vm.isLabelSelected(mockLabels[2])).toBe(false); + expect(wrapper.vm.isLabelSelected(mockLabels[1])).toBe(false); }); }); @@ -203,7 +203,7 @@ describe('DropdownContentsLabelsView', () => { it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => { jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation(); wrapper.setData({ - currentHighlightItem: 1, + currentHighlightItem: 2, }); wrapper.vm.handleKeyDown({ @@ -213,7 +213,7 @@ describe('DropdownContentsLabelsView', () => { expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([ { - ...mockLabels[1], + ...mockLabels[2], set: true, }, ]); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js index c90e63313b2..960ea77cb6e 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js @@ -6,7 +6,7 @@ import DropdownValue from '~/vue_shared/components/sidebar/labels_select_vue/dro import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; -import { mockConfig, mockRegularLabel, mockScopedLabel } from './mock_data'; +import { mockConfig, mockLabels, mockRegularLabel, mockScopedLabel } from './mock_data'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -14,6 +14,9 @@ localVue.use(Vuex); describe('DropdownValue', () => { let wrapper; + const findAllLabels = () => wrapper.findAllComponents(GlLabel); + const findLabel = (index) => findAllLabels().at(index).props('title'); + const createComponent = (initialState = {}, slots = {}) => { const store = new Vuex.Store(labelsSelectModule()); @@ -28,7 +31,6 @@ describe('DropdownValue', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('methods', () => { @@ -82,7 +84,17 @@ describe('DropdownValue', () => { it('renders labels when `selectedLabels` is not empty', () => { createComponent(); - expect(wrapper.findAll(GlLabel).length).toBe(2); + expect(findAllLabels()).toHaveLength(2); + }); + + it('orders scoped labels first', () => { + createComponent({ selectedLabels: mockLabels }); + + expect(findAllLabels()).toHaveLength(mockLabels.length); + expect(findLabel(0)).toBe('Foo::Bar'); + expect(findLabel(1)).toBe('Boog'); + expect(findLabel(2)).toBe('Bug'); + expect(findLabel(3)).toBe('Foo Label'); }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js index 730afcbecab..1faa3b0af1d 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js @@ -15,22 +15,22 @@ export const mockScopedLabel = { }; export const mockLabels = [ - mockRegularLabel, - mockScopedLabel, { - id: 28, - title: 'Bug', + id: 29, + title: 'Boog', description: 'Label for bugs', color: '#FF0000', textColor: '#FFFFFF', }, { - id: 29, - title: 'Boog', + id: 28, + title: 'Bug', description: 'Label for bugs', color: '#FF0000', textColor: '#FFFFFF', }, + mockRegularLabel, + mockScopedLabel, ]; export const mockCollapsedLabels = [ diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js deleted file mode 100644 index 0a42d389b67..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js +++ /dev/null @@ -1,91 +0,0 @@ -import { GlIcon, GlButton } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; - -import DropdownButton from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue'; - -import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store'; - -import { mockConfig } from './mock_data'; - -let store; -const localVue = createLocalVue(); -localVue.use(Vuex); - -const createComponent = (initialState = mockConfig) => { - store = new Vuex.Store(labelSelectModule()); - - store.dispatch('setInitialState', initialState); - - return shallowMount(DropdownButton, { - localVue, - store, - }); -}; - -describe('DropdownButton', () => { - let wrapper; - - beforeEach(() => { - wrapper = createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - const findDropdownButton = () => wrapper.find(GlButton); - const findDropdownText = () => wrapper.find('.dropdown-toggle-text'); - const findDropdownIcon = () => wrapper.find(GlIcon); - - describe('methods', () => { - describe('handleButtonClick', () => { - it.each` - variant | expectPropagationStopped - ${'standalone'} | ${true} - ${'embedded'} | ${false} - `( - 'toggles dropdown content and handles event propagation when `state.variant` is "$variant"', - ({ variant, expectPropagationStopped }) => { - const event = { stopPropagation: jest.fn() }; - - wrapper = createComponent({ ...mockConfig, variant }); - - findDropdownButton().vm.$emit('click', event); - - expect(store.state.showDropdownContents).toBe(true); - expect(event.stopPropagation).toHaveBeenCalledTimes(expectPropagationStopped ? 1 : 0); - }, - ); - }); - }); - - describe('template', () => { - it('renders component container element', () => { - expect(wrapper.find(GlButton).element).toBe(wrapper.element); - }); - - it('renders default button text element', () => { - const dropdownTextEl = findDropdownText(); - - expect(dropdownTextEl.exists()).toBe(true); - expect(dropdownTextEl.text()).toBe('Label'); - }); - - it('renders provided button text element', () => { - store.state.dropdownButtonText = 'Custom label'; - const dropdownTextEl = findDropdownText(); - - return wrapper.vm.$nextTick().then(() => { - expect(dropdownTextEl.text()).toBe('Custom label'); - }); - }); - - it('renders chevron icon element', () => { - const iconEl = findDropdownIcon(); - - expect(iconEl.exists()).toBe(true); - expect(iconEl.props('name')).toBe('chevron-down'); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js index 90bc1980ac3..843298a1406 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js @@ -7,7 +7,12 @@ import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue'; import createLabelMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql'; -import { mockSuggestedColors, createLabelSuccessfulResponse } from './mock_data'; +import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; +import { + mockSuggestedColors, + createLabelSuccessfulResponse, + labelsQueryResponse, +} from './mock_data'; jest.mock('~/flash'); @@ -44,6 +49,14 @@ describe('DropdownContentsCreateView', () => { const createComponent = ({ mutationHandler = createLabelSuccessHandler } = {}) => { const mockApollo = createMockApollo([[createLabelMutation, mutationHandler]]); + mockApollo.clients.defaultClient.cache.writeQuery({ + query: projectLabelsQuery, + data: labelsQueryResponse.data, + variables: { + fullPath: '', + searchTerm: '', + }, + }); wrapper = shallowMount(DropdownContentsCreateView, { localVue, diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js index 8bd944a3d54..537bbc8e71e 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js @@ -45,8 +45,6 @@ describe('DropdownContentsLabelsView', () => { provide: { projectPath: 'test', iid: 1, - allowLabelCreate: true, - labelsManagePath: '/gitlab-org/my-project/-/labels', variant: DropdownVariant.Sidebar, ...injected, }, @@ -69,10 +67,7 @@ describe('DropdownContentsLabelsView', () => { const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findLabelsList = () => wrapper.find('[data-testid="labels-list"]'); - const findDropdownWrapper = () => wrapper.find('[data-testid="dropdown-wrapper"]'); - const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]'); const findNoResultsMessage = () => wrapper.find('[data-testid="no-results"]'); - const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]'); describe('when loading labels', () => { it('renders disabled search input field', async () => { @@ -109,40 +104,6 @@ describe('DropdownContentsLabelsView', () => { expect(findLabelsList().exists()).toBe(true); expect(findLabels()).toHaveLength(2); }); - - it('changes highlighted label correctly on pressing down button', async () => { - expect(findLabels().at(0).attributes('highlight')).toBeUndefined(); - - await findDropdownWrapper().trigger('keydown.down'); - expect(findLabels().at(0).attributes('highlight')).toBe('true'); - - await findDropdownWrapper().trigger('keydown.down'); - expect(findLabels().at(1).attributes('highlight')).toBe('true'); - expect(findLabels().at(0).attributes('highlight')).toBeUndefined(); - }); - - it('changes highlighted label correctly on pressing up button', async () => { - await findDropdownWrapper().trigger('keydown.down'); - await findDropdownWrapper().trigger('keydown.down'); - expect(findLabels().at(1).attributes('highlight')).toBe('true'); - - await findDropdownWrapper().trigger('keydown.up'); - expect(findLabels().at(0).attributes('highlight')).toBe('true'); - }); - - it('changes label selected state when Enter is pressed', async () => { - expect(findLabels().at(0).attributes('islabelset')).toBeUndefined(); - await findDropdownWrapper().trigger('keydown.down'); - await findDropdownWrapper().trigger('keydown.enter'); - - expect(findLabels().at(0).attributes('islabelset')).toBe('true'); - }); - - it('emits `closeDropdown event` when Esc button is pressed', () => { - findDropdownWrapper().trigger('keydown.esc'); - - expect(wrapper.emitted('closeDropdown')).toEqual([[selectedLabels]]); - }); }); it('when search returns 0 results', async () => { @@ -170,44 +131,4 @@ describe('DropdownContentsLabelsView', () => { await waitForPromises(); expect(createFlash).toHaveBeenCalled(); }); - - it('does not render footer on standalone dropdown', () => { - createComponent({ injected: { variant: DropdownVariant.Standalone } }); - - expect(findDropdownFooter().exists()).toBe(false); - }); - - it('renders footer on sidebar dropdown', () => { - createComponent(); - - expect(findDropdownFooter().exists()).toBe(true); - }); - - it('renders footer on embedded dropdown', () => { - createComponent({ injected: { variant: DropdownVariant.Embedded } }); - - expect(findDropdownFooter().exists()).toBe(true); - }); - - it('does not render create label button if `allowLabelCreate` is false', () => { - createComponent({ injected: { allowLabelCreate: false } }); - - expect(findCreateLabelButton().exists()).toBe(false); - }); - - describe('when `allowLabelCreate` is true', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders create label button', () => { - expect(findCreateLabelButton().exists()).toBe(true); - }); - - it('emits `toggleDropdownContentsCreateView` event on create label button click', () => { - findCreateLabelButton().vm.$emit('click'); - - expect(wrapper.emitted('toggleDropdownContentsCreateView')).toEqual([[]]); - }); - }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js index 3c2fd0c5acc..a1b40a891ec 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js @@ -1,77 +1,127 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; +import { GlDropdown } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue'; -import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store'; - -import { mockConfig, mockLabels } from './mock_data'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -const createComponent = (initialState = mockConfig, defaultProps = {}) => { - const store = new Vuex.Store(labelsSelectModule()); - - store.dispatch('setInitialState', initialState); - - return shallowMount(DropdownContents, { - propsData: { - ...defaultProps, - labelsCreateTitle: 'test', - selectedLabels: mockLabels, - allowMultiselect: true, - labelsListTitle: 'Assign labels', - footerCreateLabelTitle: 'create', - footerManageLabelTitle: 'manage', - }, - localVue, - store, - }); -}; +import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue'; +import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue'; + +import { mockLabels } from './mock_data'; describe('DropdownContent', () => { let wrapper; + const createComponent = ({ props = {}, injected = {} } = {}) => { + wrapper = shallowMount(DropdownContents, { + propsData: { + labelsCreateTitle: 'test', + selectedLabels: mockLabels, + allowMultiselect: true, + labelsListTitle: 'Assign labels', + footerCreateLabelTitle: 'create', + footerManageLabelTitle: 'manage', + dropdownButtonText: 'Labels', + variant: 'sidebar', + ...props, + }, + provide: { + allowLabelCreate: true, + labelsManagePath: 'foo/bar', + ...injected, + }, + stubs: { + GlDropdown, + }, + }); + }; + beforeEach(() => { - wrapper = createComponent(); + createComponent(); }); afterEach(() => { wrapper.destroy(); }); - describe('computed', () => { - describe('dropdownContentsView', () => { - it('returns string "dropdown-contents-create-view" when `showDropdownContentsCreateView` prop is `true`', () => { - wrapper.vm.$store.dispatch('toggleDropdownContentsCreateView'); + const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]'); + const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]'); + const findGoBackButton = () => wrapper.find('[data-testid="go-back-button"]'); - expect(wrapper.vm.dropdownContentsView).toBe('dropdown-contents-create-view'); - }); + describe('Create view', () => { + beforeEach(() => { + wrapper.vm.toggleDropdownContentsCreateView(); + }); - it('returns string "dropdown-contents-labels-view" when `showDropdownContentsCreateView` prop is `false`', () => { - expect(wrapper.vm.dropdownContentsView).toBe('dropdown-contents-labels-view'); - }); + it('renders create view when `showDropdownContentsCreateView` prop is `true`', () => { + expect(wrapper.findComponent(DropdownContentsCreateView).exists()).toBe(true); + }); + + it('does not render footer', () => { + expect(findDropdownFooter().exists()).toBe(false); + }); + + it('does not render create label button', () => { + expect(findCreateLabelButton().exists()).toBe(false); + }); + + it('renders go back button', () => { + expect(findGoBackButton().exists()).toBe(true); }); }); - describe('template', () => { - it('renders component container element with class `labels-select-dropdown-contents` and no styles', () => { - expect(wrapper.attributes('class')).toContain('labels-select-dropdown-contents'); - expect(wrapper.attributes('style')).toBeUndefined(); + describe('Labels view', () => { + it('renders labels view when `showDropdownContentsCreateView` when `showDropdownContentsCreateView` prop is `false`', () => { + expect(wrapper.findComponent(DropdownContentsLabelsView).exists()).toBe(true); }); - describe('when `renderOnTop` is true', () => { - it.each` - variant | expected - ${DropdownVariant.Sidebar} | ${'bottom: 3rem'} - ${DropdownVariant.Standalone} | ${'bottom: 2rem'} - ${DropdownVariant.Embedded} | ${'bottom: 2rem'} - `('renders upward for $variant variant', ({ variant, expected }) => { - wrapper = createComponent({ ...mockConfig, variant }, { renderOnTop: true }); + it('renders footer on sidebar dropdown', () => { + expect(findDropdownFooter().exists()).toBe(true); + }); + + it('does not render footer on standalone dropdown', () => { + createComponent({ props: { variant: DropdownVariant.Standalone } }); + + expect(findDropdownFooter().exists()).toBe(false); + }); - expect(wrapper.attributes('style')).toContain(expected); + it('renders footer on embedded dropdown', () => { + createComponent({ props: { variant: DropdownVariant.Embedded } }); + + expect(findDropdownFooter().exists()).toBe(true); + }); + + it('does not render go back button', () => { + expect(findGoBackButton().exists()).toBe(false); + }); + + it('does not render create label button if `allowLabelCreate` is false', () => { + createComponent({ injected: { allowLabelCreate: false } }); + + expect(findCreateLabelButton().exists()).toBe(false); + }); + + describe('when `allowLabelCreate` is true', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders create label button', () => { + expect(findCreateLabelButton().exists()).toBe(true); }); + + it('triggers `toggleDropdownContent` method on create label button click', () => { + jest.spyOn(wrapper.vm, 'toggleDropdownContent').mockImplementation(() => {}); + findCreateLabelButton().trigger('click'); + + expect(wrapper.vm.toggleDropdownContent).toHaveBeenCalled(); + }); + }); + }); + + describe('template', () => { + it('renders component container element with classes `gl-w-full gl-mt-2` and no styles', () => { + expect(wrapper.attributes('class')).toContain('gl-w-full gl-mt-2'); + expect(wrapper.attributes('style')).toBeUndefined(); }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js deleted file mode 100644 index d2401a1f725..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js +++ /dev/null @@ -1,61 +0,0 @@ -import { GlButton, GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; - -import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue'; - -import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store'; - -import { mockConfig } from './mock_data'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -const createComponent = (initialState = mockConfig) => { - const store = new Vuex.Store(labelsSelectModule()); - - store.dispatch('setInitialState', initialState); - - return shallowMount(DropdownTitle, { - localVue, - store, - propsData: { - labelsSelectInProgress: false, - }, - }); -}; - -describe('DropdownTitle', () => { - let wrapper; - - beforeEach(() => { - wrapper = createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('template', () => { - it('renders component container element with string "Labels"', () => { - expect(wrapper.text()).toContain('Labels'); - }); - - it('renders edit link', () => { - const editBtnEl = wrapper.find(GlButton); - - expect(editBtnEl.exists()).toBe(true); - expect(editBtnEl.text()).toBe('Edit'); - }); - - it('renders loading icon element when `labelsSelectInProgress` prop is true', () => { - wrapper.setProps({ - labelsSelectInProgress: true, - }); - - return wrapper.vm.$nextTick(() => { - expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(true); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js index b3ffee2d020..e7e78cd7a33 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js @@ -9,8 +9,8 @@ describe('DropdownValue', () => { let wrapper; const findAllLabels = () => wrapper.findAllComponents(GlLabel); - const findRegularLabel = () => findAllLabels().at(0); - const findScopedLabel = () => findAllLabels().at(1); + const findRegularLabel = () => findAllLabels().at(1); + const findScopedLabel = () => findAllLabels().at(0); const findWrapper = () => wrapper.find('[data-testid="value-wrapper"]'); const findEmptyPlaceholder = () => wrapper.find('[data-testid="empty-placeholder"]'); @@ -20,11 +20,13 @@ describe('DropdownValue', () => { propsData: { selectedLabels: [mockRegularLabel, mockScopedLabel], allowLabelRemove: true, - allowScopedLabels: true, labelsFilterBasePath: '/gitlab-org/my-project/issues', labelsFilterParam: 'label_name', ...props, }, + provide: { + allowScopedLabels: true, + }, }); }; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js index 23810339833..6e8841411a2 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js @@ -1,4 +1,3 @@ -import { GlIcon, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue'; @@ -6,16 +5,10 @@ import { mockRegularLabel } from './mock_data'; const mockLabel = { ...mockRegularLabel, set: true }; -const createComponent = ({ - label = mockLabel, - isLabelSet = mockLabel.set, - highlight = true, -} = {}) => +const createComponent = ({ label = mockLabel } = {}) => shallowMount(LabelItem, { propsData: { label, - isLabelSet, - highlight, }, }); @@ -31,45 +24,6 @@ describe('LabelItem', () => { }); describe('template', () => { - it('renders gl-link component', () => { - expect(wrapper.find(GlLink).exists()).toBe(true); - }); - - it('renders component root with class `is-focused` when `highlight` prop is true', () => { - const wrapperTemp = createComponent({ - highlight: true, - }); - - expect(wrapperTemp.classes()).toContain('is-focused'); - - wrapperTemp.destroy(); - }); - - it('renders visible gl-icon component when `isLabelSet` prop is true', () => { - const wrapperTemp = createComponent({ - isLabelSet: true, - }); - - const iconEl = wrapperTemp.find(GlIcon); - - expect(iconEl.isVisible()).toBe(true); - expect(iconEl.props('name')).toBe('mobile-issue-close'); - - wrapperTemp.destroy(); - }); - - it('renders visible span element as placeholder instead of gl-icon when `isLabelSet` prop is false', () => { - const wrapperTemp = createComponent({ - isLabelSet: false, - }); - - const placeholderEl = wrapperTemp.find('[data-testid="no-icon"]'); - - expect(placeholderEl.isVisible()).toBe(true); - - wrapperTemp.destroy(); - }); - it('renders label color element', () => { const colorEl = wrapper.find('[data-testid="label-color-box"]'); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js index e17dfd93efc..a18511fa21d 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js @@ -1,193 +1,74 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; - -import { isInViewport } from '~/lib/utils/common_utils'; -import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; -import DropdownButton from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue'; +import { shallowMount } from '@vue/test-utils'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue'; -import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue'; import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue'; import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue'; import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; -import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store'; - import { mockConfig } from './mock_data'; -jest.mock('~/lib/utils/common_utils', () => ({ - isInViewport: jest.fn().mockReturnValue(true), -})); - -const localVue = createLocalVue(); -localVue.use(Vuex); - describe('LabelsSelectRoot', () => { let wrapper; - let store; const createComponent = (config = mockConfig, slots = {}) => { wrapper = shallowMount(LabelsSelectRoot, { - localVue, slots, - store, propsData: config, stubs: { - 'dropdown-contents': DropdownContents, + DropdownContents, + SidebarEditableItem, }, provide: { iid: '1', projectPath: 'test', + canUpdate: true, + allowLabelEdit: true, }, }); }; - beforeEach(() => { - store = new Vuex.Store(labelsSelectModule()); - }); - afterEach(() => { wrapper.destroy(); }); - describe('methods', () => { - describe('handleDropdownClose', () => { - beforeEach(() => { - createComponent(); - }); - - it('emits `updateSelectedLabels` & `onDropdownClose` events on component when provided `labels` param is not empty', () => { - wrapper.vm.handleDropdownClose([{ id: 1 }, { id: 2 }]); - - expect(wrapper.emitted().updateSelectedLabels).toBeTruthy(); - expect(wrapper.emitted().onDropdownClose).toBeTruthy(); - }); - - it('emits only `onDropdownClose` event on component when provided `labels` param is empty', () => { - wrapper.vm.handleDropdownClose([]); - - expect(wrapper.emitted().updateSelectedLabels).toBeFalsy(); - expect(wrapper.emitted().onDropdownClose).toBeTruthy(); - }); - }); - - describe('handleCollapsedValueClick', () => { - it('emits `toggleCollapse` event on component', () => { - createComponent(); - wrapper.vm.handleCollapsedValueClick(); - - expect(wrapper.emitted().toggleCollapse).toBeTruthy(); - }); - }); + it('renders component with classes `labels-select-wrapper position-relative`', () => { + createComponent(); + expect(wrapper.classes()).toEqual(['labels-select-wrapper', 'position-relative']); }); - describe('template', () => { - it('renders component with classes `labels-select-wrapper position-relative`', () => { - createComponent(); - expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative'); - }); - - it.each` - variant | cssClass - ${'standalone'} | ${'is-standalone'} - ${'embedded'} | ${'is-embedded'} - `( - 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"', - ({ variant, cssClass }) => { - createComponent({ - ...mockConfig, - variant, - }); - - return wrapper.vm.$nextTick(() => { - expect(wrapper.classes()).toContain(cssClass); - }); - }, - ); - - it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', async () => { - createComponent(); - await wrapper.vm.$nextTick; - expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true); - }); - - it('renders `dropdown-title` component', async () => { - createComponent(); - await wrapper.vm.$nextTick; - expect(wrapper.find(DropdownTitle).exists()).toBe(true); - }); - - it('renders `dropdown-value` component', async () => { - createComponent(mockConfig, { - default: 'None', + it.each` + variant | cssClass + ${'standalone'} | ${'is-standalone'} + ${'embedded'} | ${'is-embedded'} + `( + 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"', + ({ variant, cssClass }) => { + createComponent({ + ...mockConfig, + variant, }); - await wrapper.vm.$nextTick; - - const valueComp = wrapper.find(DropdownValue); - - expect(valueComp.exists()).toBe(true); - expect(valueComp.text()).toBe('None'); - }); - - it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', async () => { - createComponent(); - wrapper.vm.$store.dispatch('toggleDropdownButton'); - await wrapper.vm.$nextTick; - expect(wrapper.find(DropdownButton).exists()).toBe(true); - }); - - it('renders `dropdown-contents` component when `showDropdownButton` & `showDropdownContents` prop is `true`', async () => { - createComponent(); - wrapper.vm.$store.dispatch('toggleDropdownContents'); - await wrapper.vm.$nextTick; - expect(wrapper.find(DropdownContents).exists()).toBe(true); - }); - describe('sets content direction based on viewport', () => { - describe.each(Object.values(DropdownVariant))( - 'when labels variant is "%s"', - ({ variant }) => { - beforeEach(() => { - createComponent({ ...mockConfig, variant }); - wrapper.vm.$store.dispatch('toggleDropdownContents'); - }); - - it('set direction when out of viewport', () => { - isInViewport.mockImplementation(() => false); - wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(true); - }); - }); - - it('does not set direction when inside of viewport', () => { - isInViewport.mockImplementation(() => true); - wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false); - }); - }); - }, - ); - }); - }); + return wrapper.vm.$nextTick(() => { + expect(wrapper.classes()).toContain(cssClass); + }); + }, + ); - it('calls toggleDropdownContents action when isEditing prop is changing to true', async () => { + it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', async () => { createComponent(); - - jest.spyOn(store, 'dispatch').mockResolvedValue(); - await wrapper.setProps({ isEditing: true }); - - expect(store.dispatch).toHaveBeenCalledWith('toggleDropdownContents'); + await wrapper.vm.$nextTick; + expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true); }); - it('does not call toggleDropdownContents action when isEditing prop is changing to false', async () => { - createComponent(); + it('renders `dropdown-value` component', async () => { + createComponent(mockConfig, { + default: 'None', + }); + await wrapper.vm.$nextTick; - jest.spyOn(store, 'dispatch').mockResolvedValue(); - await wrapper.setProps({ isEditing: false }); + const valueComp = wrapper.find(DropdownValue); - expect(store.dispatch).not.toHaveBeenCalled(); + expect(valueComp.exists()).toBe(true); + expect(valueComp.text()).toBe('None'); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js index 5dd8fc1b8b2..fceaabec2d0 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js @@ -34,18 +34,12 @@ export const mockLabels = [ ]; export const mockConfig = { - allowLabelEdit: true, - allowLabelCreate: true, - allowScopedLabels: true, allowMultiselect: true, labelsListTitle: 'Assign labels', labelsCreateTitle: 'Create label', variant: 'sidebar', - dropdownOnly: false, selectedLabels: [mockRegularLabel, mockScopedLabel], labelsSelectInProgress: false, - labelsFetchPath: '/gitlab-org/my-project/-/labels.json', - labelsManagePath: '/gitlab-org/my-project/-/labels', labelsFilterBasePath: '/gitlab-org/my-project/issues', labelsFilterParam: 'label_name', footerCreateLabelTitle: 'create', @@ -83,9 +77,7 @@ export const createLabelSuccessfulResponse = { id: 'gid://gitlab/ProjectLabel/126', color: '#dc143c', description: null, - descriptionHtml: '', title: 'ewrwrwer', - textColor: '#FFFFFF', __typename: 'Label', }, errors: [], diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js deleted file mode 100644 index ee905410ffa..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js +++ /dev/null @@ -1,85 +0,0 @@ -import testAction from 'helpers/vuex_action_helper'; -import * as actions from '~/vue_shared/components/sidebar/labels_select_widget/store/actions'; -import * as types from '~/vue_shared/components/sidebar/labels_select_widget/store/mutation_types'; -import defaultState from '~/vue_shared/components/sidebar/labels_select_widget/store/state'; - -jest.mock('~/flash'); - -describe('LabelsSelect Actions', () => { - let state; - const mockInitialState = { - labels: [], - selectedLabels: [], - }; - - beforeEach(() => { - state = { ...defaultState() }; - }); - - describe('setInitialState', () => { - it('sets initial store state', (done) => { - testAction( - actions.setInitialState, - mockInitialState, - state, - [{ type: types.SET_INITIAL_STATE, payload: mockInitialState }], - [], - done, - ); - }); - }); - - describe('toggleDropdownButton', () => { - it('toggles dropdown button', (done) => { - testAction( - actions.toggleDropdownButton, - {}, - state, - [{ type: types.TOGGLE_DROPDOWN_BUTTON }], - [], - done, - ); - }); - }); - - describe('toggleDropdownContents', () => { - it('toggles dropdown contents', (done) => { - testAction( - actions.toggleDropdownContents, - {}, - state, - [{ type: types.TOGGLE_DROPDOWN_CONTENTS }], - [], - done, - ); - }); - }); - - describe('toggleDropdownContentsCreateView', () => { - it('toggles dropdown create view', (done) => { - testAction( - actions.toggleDropdownContentsCreateView, - {}, - state, - [{ type: types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW }], - [], - done, - ); - }); - }); - - describe('updateSelectedLabels', () => { - it('updates `state.labels` based on provided `labels` param', (done) => { - const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - - testAction( - actions.updateSelectedLabels, - labels, - state, - [{ type: types.UPDATE_SELECTED_LABELS, payload: { labels } }], - [], - done, - ); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js deleted file mode 100644 index 40eb0323146..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js +++ /dev/null @@ -1,59 +0,0 @@ -import * as getters from '~/vue_shared/components/sidebar/labels_select_widget/store/getters'; - -describe('LabelsSelect Getters', () => { - describe('dropdownButtonText', () => { - it.each` - labelType | dropdownButtonText | expected - ${'default'} | ${''} | ${'Label'} - ${'custom'} | ${'Custom label'} | ${'Custom label'} - `( - 'returns $labelType text when state.labels has no selected labels', - ({ dropdownButtonText, expected }) => { - const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - const selectedLabels = []; - const state = { labels, selectedLabels, dropdownButtonText }; - - expect(getters.dropdownButtonText(state, {})).toBe(expected); - }, - ); - - it('returns label title when state.labels has only 1 label', () => { - const labels = [{ id: 1, title: 'Foobar', set: true }]; - - expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe( - 'Foobar', - ); - }); - - it('returns first label title and remaining labels count when state.labels has more than 1 label', () => { - const labels = [ - { id: 1, title: 'Foo', set: true }, - { id: 2, title: 'Bar', set: true }, - ]; - - expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe( - 'Foo +1 more', - ); - }); - }); - - describe('selectedLabelsList', () => { - it('returns array of IDs of all labels within `state.selectedLabels`', () => { - const selectedLabels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - - expect(getters.selectedLabelsList({ selectedLabels })).toEqual([1, 2, 3, 4]); - }); - }); - - describe('isDropdownVariantSidebar', () => { - it('returns `true` when `state.variant` is "sidebar"', () => { - expect(getters.isDropdownVariantSidebar({ variant: 'sidebar' })).toBe(true); - }); - }); - - describe('isDropdownVariantStandalone', () => { - it('returns `true` when `state.variant` is "standalone"', () => { - expect(getters.isDropdownVariantStandalone({ variant: 'standalone' })).toBe(true); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js deleted file mode 100644 index 1f0e0eee420..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js +++ /dev/null @@ -1,115 +0,0 @@ -import * as types from '~/vue_shared/components/sidebar/labels_select_widget/store/mutation_types'; -import mutations from '~/vue_shared/components/sidebar/labels_select_widget/store/mutations'; - -describe('LabelsSelect Mutations', () => { - describe(`${types.SET_INITIAL_STATE}`, () => { - it('initializes provided props to store state', () => { - const state = {}; - mutations[types.SET_INITIAL_STATE](state, { - labels: 'foo', - }); - - expect(state.labels).toEqual('foo'); - }); - }); - - describe(`${types.TOGGLE_DROPDOWN_BUTTON}`, () => { - it('toggles value of `state.showDropdownButton`', () => { - const state = { - showDropdownButton: false, - }; - mutations[types.TOGGLE_DROPDOWN_BUTTON](state); - - expect(state.showDropdownButton).toBe(true); - }); - }); - - describe(`${types.TOGGLE_DROPDOWN_CONTENTS}`, () => { - it('toggles value of `state.showDropdownButton` when `state.dropdownOnly` is false', () => { - const state = { - dropdownOnly: false, - showDropdownButton: false, - variant: 'sidebar', - }; - mutations[types.TOGGLE_DROPDOWN_CONTENTS](state); - - expect(state.showDropdownButton).toBe(true); - }); - - it('toggles value of `state.showDropdownContents`', () => { - const state = { - showDropdownContents: false, - }; - mutations[types.TOGGLE_DROPDOWN_CONTENTS](state); - - expect(state.showDropdownContents).toBe(true); - }); - - it('sets value of `state.showDropdownContentsCreateView` to `false` when `showDropdownContents` is true', () => { - const state = { - showDropdownContents: false, - showDropdownContentsCreateView: true, - }; - mutations[types.TOGGLE_DROPDOWN_CONTENTS](state); - - expect(state.showDropdownContentsCreateView).toBe(false); - }); - }); - - describe(`${types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW}`, () => { - it('toggles value of `state.showDropdownContentsCreateView`', () => { - const state = { - showDropdownContentsCreateView: false, - }; - mutations[types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state); - - expect(state.showDropdownContentsCreateView).toBe(true); - }); - }); - - describe(`${types.UPDATE_SELECTED_LABELS}`, () => { - let labels; - - beforeEach(() => { - labels = [ - { id: 1, title: 'scoped::test', set: true }, - { id: 2, set: false, title: 'scoped::one' }, - { id: 3, title: '' }, - { id: 4, title: '' }, - ]; - }); - - it('updates `state.labels` to include `touched` and `set` props based on provided `labels` param', () => { - const updatedLabelIds = [2]; - const state = { - labels, - }; - mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: 2 }] }); - - state.labels.forEach((label) => { - if (updatedLabelIds.includes(label.id)) { - expect(label.touched).toBe(true); - expect(label.set).toBe(true); - } - }); - }); - - describe('when label is scoped', () => { - it('unsets the currently selected scoped label and sets the current label', () => { - const state = { - labels, - }; - mutations[types.UPDATE_SELECTED_LABELS](state, { - labels: [{ id: 2, title: 'scoped::one' }], - }); - - expect(state.labels).toEqual([ - { id: 1, title: 'scoped::test', set: false }, - { id: 2, set: true, title: 'scoped::one', touched: true }, - { id: 3, title: '' }, - { id: 4, title: '' }, - ]); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/storage_counter/usage_graph_spec.js b/spec/frontend/vue_shared/components/storage_counter/usage_graph_spec.js new file mode 100644 index 00000000000..103eee4b9a8 --- /dev/null +++ b/spec/frontend/vue_shared/components/storage_counter/usage_graph_spec.js @@ -0,0 +1,137 @@ +import { shallowMount } from '@vue/test-utils'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import UsageGraph from '~/vue_shared/components/storage_counter/usage_graph.vue'; + +let data; +let wrapper; + +function mountComponent({ rootStorageStatistics, limit }) { + wrapper = shallowMount(UsageGraph, { + propsData: { + rootStorageStatistics, + limit, + }, + }); +} +function findStorageTypeUsagesSerialized() { + return wrapper + .findAll('[data-testid="storage-type-usage"]') + .wrappers.map((wp) => wp.element.style.flex); +} + +describe('Storage Counter usage graph component', () => { + beforeEach(() => { + data = { + rootStorageStatistics: { + wikiSize: 5000, + repositorySize: 4000, + packagesSize: 3000, + lfsObjectsSize: 2000, + buildArtifactsSize: 500, + pipelineArtifactsSize: 500, + snippetsSize: 2000, + storageSize: 17000, + uploadsSize: 1000, + }, + limit: 2000, + }; + mountComponent(data); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the legend in order', () => { + const types = wrapper.findAll('[data-testid="storage-type-legend"]'); + + const { + buildArtifactsSize, + pipelineArtifactsSize, + lfsObjectsSize, + packagesSize, + repositorySize, + wikiSize, + snippetsSize, + uploadsSize, + } = data.rootStorageStatistics; + + expect(types.at(0).text()).toMatchInterpolatedText(`Wikis ${numberToHumanSize(wikiSize)}`); + expect(types.at(1).text()).toMatchInterpolatedText( + `Repositories ${numberToHumanSize(repositorySize)}`, + ); + expect(types.at(2).text()).toMatchInterpolatedText( + `Packages ${numberToHumanSize(packagesSize)}`, + ); + expect(types.at(3).text()).toMatchInterpolatedText( + `LFS Objects ${numberToHumanSize(lfsObjectsSize)}`, + ); + expect(types.at(4).text()).toMatchInterpolatedText( + `Snippets ${numberToHumanSize(snippetsSize)}`, + ); + expect(types.at(5).text()).toMatchInterpolatedText( + `Artifacts ${numberToHumanSize(buildArtifactsSize + pipelineArtifactsSize)}`, + ); + expect(types.at(6).text()).toMatchInterpolatedText(`Uploads ${numberToHumanSize(uploadsSize)}`); + }); + + describe('when storage type is not used', () => { + beforeEach(() => { + data.rootStorageStatistics.wikiSize = 0; + mountComponent(data); + }); + + it('filters the storage type', () => { + expect(wrapper.text()).not.toContain('Wikis'); + }); + }); + + describe('when there is no storage usage', () => { + beforeEach(() => { + data.rootStorageStatistics.storageSize = 0; + mountComponent(data); + }); + + it('it does not render', () => { + expect(wrapper.html()).toEqual(''); + }); + }); + + describe('when limit is 0', () => { + beforeEach(() => { + data.limit = 0; + mountComponent(data); + }); + + it('sets correct flex values', () => { + expect(findStorageTypeUsagesSerialized()).toStrictEqual([ + '0.29411764705882354', + '0.23529411764705882', + '0.17647058823529413', + '0.11764705882352941', + '0.11764705882352941', + '0.058823529411764705', + '0.058823529411764705', + ]); + }); + }); + + describe('when storage exceeds limit', () => { + beforeEach(() => { + data.limit = data.rootStorageStatistics.storageSize - 1; + mountComponent(data); + }); + + it('it does render correclty', () => { + expect(findStorageTypeUsagesSerialized()).toStrictEqual([ + '0.29411764705882354', + '0.23529411764705882', + '0.17647058823529413', + '0.11764705882352941', + '0.11764705882352941', + '0.058823529411764705', + '0.058823529411764705', + ]); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js index 538e67ef354..926223e0670 100644 --- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js +++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js @@ -94,7 +94,7 @@ describe('User Popover Component', () => { const bio = 'My super interesting bio'; it('should show only bio if work information is not available', () => { - const user = { ...DEFAULT_PROPS.user, bio, bioHtml: bio }; + const user = { ...DEFAULT_PROPS.user, bio }; createWrapper({ user }); @@ -117,7 +117,6 @@ describe('User Popover Component', () => { const user = { ...DEFAULT_PROPS.user, bio, - bioHtml: bio, workInformation: 'Frontend Engineer at GitLab', }; @@ -127,16 +126,15 @@ describe('User Popover Component', () => { expect(findWorkInformation().text()).toBe('Frontend Engineer at GitLab'); }); - it('should not encode special characters in bio', () => { + it('should encode special characters in bio', () => { const user = { ...DEFAULT_PROPS.user, - bio: 'I like CSS', - bioHtml: 'I like <b>CSS</b>', + bio: 'I like <b>CSS</b>', }; createWrapper({ user }); - expect(findBio().html()).toContain('I like <b>CSS</b>'); + expect(findBio().html()).toContain('I like <b>CSS</b>'); }); it('shows icon for bio', () => { @@ -250,6 +248,13 @@ describe('User Popover Component', () => { const securityBotDocsLink = findSecurityBotDocsLink(); expect(securityBotDocsLink.exists()).toBe(true); expect(securityBotDocsLink.attributes('href')).toBe(SECURITY_BOT_USER.websiteUrl); + expect(securityBotDocsLink.text()).toBe('Learn more about GitLab Security Bot'); + }); + + it("doesn't escape user's name", () => { + createWrapper({ user: { ...SECURITY_BOT_USER, name: '%<>\';"' } }); + const securityBotDocsLink = findSecurityBotDocsLink(); + expect(securityBotDocsLink.text()).toBe('Learn more about %<>\';"'); }); }); }); diff --git a/spec/frontend/zen_mode_spec.js b/spec/frontend/zen_mode_spec.js index bf4b57d8afb..13f221fd9d9 100644 --- a/spec/frontend/zen_mode_spec.js +++ b/spec/frontend/zen_mode_spec.js @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import Dropzone from 'dropzone'; import $ from 'jquery'; import Mousetrap from 'mousetrap'; -import initNotes from '~/init_notes'; +import GLForm from '~/gl_form'; import * as utils from '~/lib/utils/common_utils'; import ZenMode from '~/zen_mode'; @@ -34,7 +34,9 @@ describe('ZenMode', () => { mock.onGet().reply(200); loadFixtures(fixtureName); - initNotes(); + + const form = $('.js-new-note-form'); + new GLForm(form); // eslint-disable-line no-new dropzoneForElementSpy = jest.spyOn(Dropzone, 'forElement').mockImplementation(() => ({ enable: () => true, |