diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-04-20 17:36:54 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-04-20 17:36:54 +0300 |
commit | f61bb2a16a514b71bf33aabbbb999d6732016a24 (patch) | |
tree | 9548caa89e60b4f40b99bbd1dac030420b812aa8 /spec/frontend | |
parent | 35fc54e5d261f8898e390aea7c2f5ec5fdf0539d (diff) |
Add latest changes from gitlab-org/gitlab@13-11-stable-eev13.11.0-rc42
Diffstat (limited to 'spec/frontend')
379 files changed, 16634 insertions, 4933 deletions
diff --git a/spec/frontend/__helpers__/experimentation_helper.js b/spec/frontend/__helpers__/experimentation_helper.js index c08c25155e8..7a2ef61216a 100644 --- a/spec/frontend/__helpers__/experimentation_helper.js +++ b/spec/frontend/__helpers__/experimentation_helper.js @@ -12,3 +12,16 @@ export function withGonExperiment(experimentKey, value = true) { window.gon = origGon; }); } +// This helper is for specs that use `gitlab-experiment` utilities, which have a different schema that gets pushed via Gon compared to `Experimentation Module` +export function assignGitlabExperiment(experimentKey, variant) { + let origGon; + + beforeEach(() => { + origGon = window.gon; + window.gon = { experiment: { [experimentKey]: { variant } } }; + }); + + afterEach(() => { + window.gon = origGon; + }); +} diff --git a/spec/frontend/__helpers__/mock_apollo_helper.js b/spec/frontend/__helpers__/mock_apollo_helper.js index 914cce1d662..bd97a06071a 100644 --- a/spec/frontend/__helpers__/mock_apollo_helper.js +++ b/spec/frontend/__helpers__/mock_apollo_helper.js @@ -2,11 +2,15 @@ import { InMemoryCache } from 'apollo-cache-inmemory'; import { createMockClient } from 'mock-apollo-client'; import VueApollo from 'vue-apollo'; -export default (handlers = [], resolvers = {}) => { - const fragmentMatcher = { match: () => true }; +const defaultCacheOptions = { + fragmentMatcher: { match: () => true }, + addTypename: false, +}; + +export default (handlers = [], resolvers = {}, cacheOptions = {}) => { const cache = new InMemoryCache({ - fragmentMatcher, - addTypename: false, + ...defaultCacheOptions, + ...cacheOptions, }); const mockClient = createMockClient({ cache, resolvers }); diff --git a/spec/frontend/__helpers__/vue_test_utils_helper.js b/spec/frontend/__helpers__/vue_test_utils_helper.js index d6132ef84ac..a94cee84f74 100644 --- a/spec/frontend/__helpers__/vue_test_utils_helper.js +++ b/spec/frontend/__helpers__/vue_test_utils_helper.js @@ -1,4 +1,6 @@ -import { isArray } from 'lodash'; +import * as testingLibrary from '@testing-library/dom'; +import { createWrapper, WrapperArray, mount, shallowMount } from '@vue/test-utils'; +import { isArray, upperFirst } from 'lodash'; const vNodeContainsText = (vnode, text) => (vnode.text && vnode.text.includes(text)) || @@ -37,6 +39,17 @@ export const waitForMutation = (store, expectedMutationType) => }); export const extendedWrapper = (wrapper) => { + // https://testing-library.com/docs/queries/about + const AVAILABLE_QUERIES = [ + 'byRole', + 'byLabelText', + 'byPlaceholderText', + 'byText', + 'byDisplayValue', + 'byAltText', + 'byTitle', + ]; + if (isArray(wrapper) || !wrapper?.find) { // eslint-disable-next-line no-console console.warn( @@ -56,5 +69,63 @@ export const extendedWrapper = (wrapper) => { return this.findAll(`[data-testid="${id}"]`); }, }, + // `findBy` + ...AVAILABLE_QUERIES.reduce((accumulator, query) => { + return { + ...accumulator, + [`find${upperFirst(query)}`]: { + value(text, options = {}) { + const elements = testingLibrary[`queryAll${upperFirst(query)}`]( + wrapper.element, + text, + options, + ); + + // Return VTU `ErrorWrapper` if element is not found + // https://github.com/vuejs/vue-test-utils/blob/dev/packages/test-utils/src/error-wrapper.js + // VTU does not expose `ErrorWrapper` so, as of now, this is the best way to + // create an `ErrorWrapper` + if (!elements.length) { + const emptyElement = document.createElement('div'); + + return createWrapper(emptyElement).find('testing-library-element-not-found'); + } + + return createWrapper(elements[0], this.options || {}); + }, + }, + }; + }, {}), + // `findAllBy` + ...AVAILABLE_QUERIES.reduce((accumulator, query) => { + return { + ...accumulator, + [`findAll${upperFirst(query)}`]: { + value(text, options = {}) { + const elements = testingLibrary[`queryAll${upperFirst(query)}`]( + wrapper.element, + text, + options, + ); + + const wrappers = elements.map((element) => { + const elementWrapper = createWrapper(element, this.options || {}); + elementWrapper.selector = text; + + return elementWrapper; + }); + + const wrapperArray = new WrapperArray(wrappers); + wrapperArray.selector = text; + + return wrapperArray; + }, + }, + }; + }, {}), }); }; + +export const shallowMountExtended = (...args) => extendedWrapper(shallowMount(...args)); + +export const mountExtended = (...args) => extendedWrapper(mount(...args)); diff --git a/spec/frontend/__helpers__/vue_test_utils_helper_spec.js b/spec/frontend/__helpers__/vue_test_utils_helper_spec.js index d4f8e36c169..dfe5a483223 100644 --- a/spec/frontend/__helpers__/vue_test_utils_helper_spec.js +++ b/spec/frontend/__helpers__/vue_test_utils_helper_spec.js @@ -1,7 +1,27 @@ -import { shallowMount } from '@vue/test-utils'; -import { extendedWrapper, shallowWrapperContainsSlotText } from './vue_test_utils_helper'; +import * as testingLibrary from '@testing-library/dom'; +import * as vtu from '@vue/test-utils'; +import { + shallowMount, + Wrapper as VTUWrapper, + WrapperArray as VTUWrapperArray, +} from '@vue/test-utils'; +import { + extendedWrapper, + shallowMountExtended, + mountExtended, + shallowWrapperContainsSlotText, +} from './vue_test_utils_helper'; + +jest.mock('@testing-library/dom', () => ({ + __esModule: true, + ...jest.requireActual('@testing-library/dom'), +})); describe('Vue test utils helpers', () => { + afterAll(() => { + jest.unmock('@testing-library/dom'); + }); + describe('shallowWrapperContainsSlotText', () => { const mockText = 'text'; const mockSlot = `<div>${mockText}</div>`; @@ -84,7 +104,7 @@ describe('Vue test utils helpers', () => { ); }); - it('should find the component by test id', () => { + it('should find the element by test id', () => { expect(mockComponent.findByTestId(testId).exists()).toBe(true); }); }); @@ -105,5 +125,187 @@ describe('Vue test utils helpers', () => { expect(mockComponent.findAllByTestId(testId)).toHaveLength(2); }); }); + + describe.each` + findMethod | expectedQuery + ${'findByRole'} | ${'queryAllByRole'} + ${'findByLabelText'} | ${'queryAllByLabelText'} + ${'findByPlaceholderText'} | ${'queryAllByPlaceholderText'} + ${'findByText'} | ${'queryAllByText'} + ${'findByDisplayValue'} | ${'queryAllByDisplayValue'} + ${'findByAltText'} | ${'queryAllByAltText'} + `('$findMethod', ({ findMethod, expectedQuery }) => { + const text = 'foo bar'; + const options = { selector: 'div' }; + const mockDiv = document.createElement('div'); + + let wrapper; + beforeEach(() => { + wrapper = extendedWrapper( + shallowMount({ + template: `<div>foo bar</div>`, + }), + ); + }); + + it(`calls Testing Library \`${expectedQuery}\` function with correct parameters`, () => { + jest.spyOn(testingLibrary, expectedQuery).mockImplementation(() => [mockDiv]); + + wrapper[findMethod](text, options); + + expect(testingLibrary[expectedQuery]).toHaveBeenLastCalledWith( + wrapper.element, + text, + options, + ); + }); + + describe('when element is found', () => { + beforeEach(() => { + jest.spyOn(testingLibrary, expectedQuery).mockImplementation(() => [mockDiv]); + jest.spyOn(vtu, 'createWrapper'); + }); + + it('returns a VTU wrapper', () => { + const result = wrapper[findMethod](text, options); + + expect(vtu.createWrapper).toHaveBeenCalledWith(mockDiv, wrapper.options); + expect(result).toBeInstanceOf(VTUWrapper); + }); + }); + + describe('when multiple elements are found', () => { + beforeEach(() => { + const mockSpan = document.createElement('span'); + jest.spyOn(testingLibrary, expectedQuery).mockImplementation(() => [mockDiv, mockSpan]); + jest.spyOn(vtu, 'createWrapper'); + }); + + it('returns the first element as a VTU wrapper', () => { + const result = wrapper[findMethod](text, options); + + expect(vtu.createWrapper).toHaveBeenCalledWith(mockDiv, wrapper.options); + expect(result).toBeInstanceOf(VTUWrapper); + }); + }); + + describe('when element is not found', () => { + beforeEach(() => { + jest.spyOn(testingLibrary, expectedQuery).mockImplementation(() => []); + }); + + it('returns a VTU error wrapper', () => { + expect(wrapper[findMethod](text, options).exists()).toBe(false); + }); + }); + }); + + describe.each` + findMethod | expectedQuery + ${'findAllByRole'} | ${'queryAllByRole'} + ${'findAllByLabelText'} | ${'queryAllByLabelText'} + ${'findAllByPlaceholderText'} | ${'queryAllByPlaceholderText'} + ${'findAllByText'} | ${'queryAllByText'} + ${'findAllByDisplayValue'} | ${'queryAllByDisplayValue'} + ${'findAllByAltText'} | ${'queryAllByAltText'} + `('$findMethod', ({ findMethod, expectedQuery }) => { + const text = 'foo bar'; + const options = { selector: 'div' }; + const mockElements = [ + document.createElement('li'), + document.createElement('li'), + document.createElement('li'), + ]; + + let wrapper; + beforeEach(() => { + wrapper = extendedWrapper( + shallowMount({ + template: ` + <ul> + <li>foo</li> + <li>bar</li> + <li>baz</li> + </ul> + `, + }), + ); + }); + + it(`calls Testing Library \`${expectedQuery}\` function with correct parameters`, () => { + jest.spyOn(testingLibrary, expectedQuery).mockImplementation(() => mockElements); + + wrapper[findMethod](text, options); + + expect(testingLibrary[expectedQuery]).toHaveBeenLastCalledWith( + wrapper.element, + text, + options, + ); + }); + + describe('when elements are found', () => { + beforeEach(() => { + jest.spyOn(testingLibrary, expectedQuery).mockImplementation(() => mockElements); + }); + + it('returns a VTU wrapper array', () => { + const result = wrapper[findMethod](text, options); + + expect(result).toBeInstanceOf(VTUWrapperArray); + expect( + result.wrappers.every( + (resultWrapper) => + resultWrapper instanceof VTUWrapper && resultWrapper.options === wrapper.options, + ), + ).toBe(true); + expect(result.length).toBe(3); + }); + }); + + describe('when elements are not found', () => { + beforeEach(() => { + jest.spyOn(testingLibrary, expectedQuery).mockImplementation(() => []); + }); + + it('returns an empty VTU wrapper array', () => { + const result = wrapper[findMethod](text, options); + + expect(result).toBeInstanceOf(VTUWrapperArray); + expect(result.length).toBe(0); + }); + }); + }); + }); + + describe.each` + mountExtendedFunction | expectedMountFunction + ${shallowMountExtended} | ${'shallowMount'} + ${mountExtended} | ${'mount'} + `('$mountExtendedFunction', ({ mountExtendedFunction, expectedMountFunction }) => { + const FakeComponent = jest.fn(); + const options = { + propsData: { + foo: 'bar', + }, + }; + + beforeEach(() => { + const mockWrapper = { find: jest.fn() }; + jest.spyOn(vtu, expectedMountFunction).mockImplementation(() => mockWrapper); + }); + + it(`calls \`${expectedMountFunction}\` with passed arguments`, () => { + mountExtendedFunction(FakeComponent, options); + + expect(vtu[expectedMountFunction]).toHaveBeenCalledWith(FakeComponent, options); + }); + + it('returns extended wrapper', () => { + const result = mountExtendedFunction(FakeComponent, options); + + expect(result).toHaveProperty('find'); + expect(result).toHaveProperty('findByTestId'); + }); }); }); diff --git a/spec/frontend/__helpers__/web_worker_fake.js b/spec/frontend/__helpers__/web_worker_fake.js new file mode 100644 index 00000000000..041a9bd8540 --- /dev/null +++ b/spec/frontend/__helpers__/web_worker_fake.js @@ -0,0 +1,71 @@ +import path from 'path'; + +const isRelative = (pathArg) => pathArg.startsWith('.'); + +const transformRequirePath = (base, pathArg) => { + if (!isRelative(pathArg)) { + return pathArg; + } + + return path.resolve(base, pathArg); +}; + +const createRelativeRequire = (filename) => { + const rel = path.relative(__dirname, path.dirname(filename)); + const base = path.resolve(__dirname, rel); + + // reason: Dynamic require should be fine here since the code is dynamically evaluated anyways. + // eslint-disable-next-line import/no-dynamic-require, global-require + return (pathArg) => require(transformRequirePath(base, pathArg)); +}; + +/** + * Simulates a WebWorker module similar to the kind created by Webpack's [`worker-loader`][1] + * + * [1]: https://webpack.js.org/loaders/worker-loader/ + */ +export class FakeWebWorker { + /** + * Constructs a new FakeWebWorker instance + * + * @param {String} filename is the full path of the code, which is used to resolve relative imports. + * @param {String} code is the raw code of the web worker, which is dynamically evaluated on construction. + */ + constructor(filename, code) { + let isAlive = true; + + const clientTarget = new EventTarget(); + const workerTarget = new EventTarget(); + + this.addEventListener = (...args) => clientTarget.addEventListener(...args); + this.removeEventListener = (...args) => clientTarget.removeEventListener(...args); + this.postMessage = (message) => { + if (!isAlive) { + return; + } + + workerTarget.dispatchEvent(new MessageEvent('message', { data: message })); + }; + this.terminate = () => { + isAlive = false; + }; + + const workerScope = { + addEventListener: (...args) => workerTarget.addEventListener(...args), + removeEventListener: (...args) => workerTarget.removeEventListener(...args), + postMessage: (message) => { + if (!isAlive) { + return; + } + + clientTarget.dispatchEvent(new MessageEvent('message', { data: message })); + }, + }; + + // reason: `no-new-func` is like `eval` except it only executed on global scope and it's easy + // to pass in local references. `eval` is very unsafe in production, but in our test environment + // we shold be fine. + // eslint-disable-next-line no-new-func + Function('self', 'require', code)(workerScope, createRelativeRequire(filename)); + } +} diff --git a/spec/frontend/__helpers__/web_worker_mock.js b/spec/frontend/__helpers__/web_worker_mock.js deleted file mode 100644 index 2b4a391e1d2..00000000000 --- a/spec/frontend/__helpers__/web_worker_mock.js +++ /dev/null @@ -1,10 +0,0 @@ -/* eslint-disable class-methods-use-this */ -export default class WebWorkerMock { - addEventListener() {} - - removeEventListener() {} - - terminate() {} - - postMessage() {} -} diff --git a/spec/frontend/__helpers__/web_worker_transformer.js b/spec/frontend/__helpers__/web_worker_transformer.js new file mode 100644 index 00000000000..5b2f7d77947 --- /dev/null +++ b/spec/frontend/__helpers__/web_worker_transformer.js @@ -0,0 +1,18 @@ +/* eslint-disable import/no-commonjs */ +const babelJestTransformer = require('babel-jest'); + +// This Jest will transform the code of a WebWorker module into a FakeWebWorker subclass. +// This is meant to mirror Webpack's [`worker-loader`][1]. +// [1]: https://webpack.js.org/loaders/worker-loader/ +module.exports = { + process: (contentArg, filename, ...args) => { + const { code: content } = babelJestTransformer.process(contentArg, filename, ...args); + + return `const { FakeWebWorker } = require("helpers/web_worker_fake"); + module.exports = class JestTransformedWorker extends FakeWebWorker { + constructor() { + super(${JSON.stringify(filename)}, ${JSON.stringify(content)}); + } + };`; + }, +}; diff --git a/spec/frontend/__mocks__/vue/index.js b/spec/frontend/__mocks__/vue/index.js new file mode 100644 index 00000000000..52a5c6c5fcd --- /dev/null +++ b/spec/frontend/__mocks__/vue/index.js @@ -0,0 +1,7 @@ +import Vue from 'vue'; + +Vue.config.productionTip = false; +Vue.config.devtools = false; + +export default Vue; +export * from 'vue'; diff --git a/spec/frontend/access_tokens/index_spec.js b/spec/frontend/access_tokens/index_spec.js index e3f17e21739..1d8ac7cec25 100644 --- a/spec/frontend/access_tokens/index_spec.js +++ b/spec/frontend/access_tokens/index_spec.js @@ -25,18 +25,22 @@ describe('access tokens', () => { }); describe.each` - initFunction | mountSelector | expectedComponent - ${initExpiresAtField} | ${'js-access-tokens-expires-at'} | ${ExpiresAtField} - ${initProjectsField} | ${'js-access-tokens-projects'} | ${ProjectsField} - `('$initFunction', ({ initFunction, mountSelector, expectedComponent }) => { + initFunction | mountSelector | fieldName | expectedComponent + ${initExpiresAtField} | ${'js-access-tokens-expires-at'} | ${'expiresAt'} | ${ExpiresAtField} + ${initProjectsField} | ${'js-access-tokens-projects'} | ${'projects'} | ${ProjectsField} + `('$initFunction', ({ initFunction, mountSelector, fieldName, expectedComponent }) => { describe('when mount element exists', () => { + const nameAttribute = `access_tokens[${fieldName}]`; + const idAttribute = `access_tokens_${fieldName}`; + beforeEach(() => { const mountEl = document.createElement('div'); mountEl.classList.add(mountSelector); const input = document.createElement('input'); - input.setAttribute('name', 'foo-bar'); - input.setAttribute('id', 'foo-bar'); + input.setAttribute('name', nameAttribute); + input.setAttribute('data-js-name', fieldName); + input.setAttribute('id', idAttribute); input.setAttribute('placeholder', 'Foo bar'); input.setAttribute('value', '1,2'); @@ -57,8 +61,8 @@ describe('access tokens', () => { expect(component.exists()).toBe(true); expect(component.props('inputAttrs')).toEqual({ - name: 'foo-bar', - id: 'foo-bar', + name: nameAttribute, + id: idAttribute, value: '1,2', placeholder: 'Foo bar', }); diff --git a/spec/frontend/admin/signup_restrictions/components/signup_checkbox_spec.js b/spec/frontend/admin/signup_restrictions/components/signup_checkbox_spec.js new file mode 100644 index 00000000000..ae9b6f57ee0 --- /dev/null +++ b/spec/frontend/admin/signup_restrictions/components/signup_checkbox_spec.js @@ -0,0 +1,66 @@ +import { GlFormCheckbox } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import SignupCheckbox from '~/pages/admin/application_settings/general/components/signup_checkbox.vue'; + +describe('Signup Form', () => { + let wrapper; + + const props = { + name: 'name', + helpText: 'some help text', + label: 'a label', + value: true, + dataQaSelector: 'qa_selector', + }; + + const mountComponent = () => { + wrapper = shallowMount(SignupCheckbox, { + propsData: props, + stubs: { + GlFormCheckbox, + }, + }); + }; + + const findByTestId = (id) => wrapper.find(`[data-testid="${id}"]`); + const findHiddenInput = () => findByTestId('input'); + const findCheckbox = () => wrapper.find(GlFormCheckbox); + const findCheckboxLabel = () => findByTestId('label'); + const findHelpText = () => findByTestId('helpText'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('Signup Checkbox', () => { + beforeEach(() => { + mountComponent(); + }); + + describe('hidden input element', () => { + it('gets passed correct values from props', () => { + expect(findHiddenInput().attributes('name')).toBe(props.name); + + expect(findHiddenInput().attributes('value')).toBe('1'); + }); + }); + + describe('checkbox', () => { + it('gets passed correct checked value', () => { + expect(findCheckbox().attributes('checked')).toBe('true'); + }); + + it('gets passed correct label', () => { + expect(findCheckboxLabel().text()).toBe(props.label); + }); + + it('gets passed correct help text', () => { + expect(findHelpText().text()).toBe(props.helpText); + }); + + it('gets passed data qa selector', () => { + expect(findCheckbox().attributes('data-qa-selector')).toBe(props.dataQaSelector); + }); + }); + }); +}); diff --git a/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js b/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js new file mode 100644 index 00000000000..18339164d5a --- /dev/null +++ b/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js @@ -0,0 +1,331 @@ +import { GlButton, GlModal } from '@gitlab/ui'; +import { within, fireEvent } from '@testing-library/dom'; +import { shallowMount, mount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import SignupForm from '~/pages/admin/application_settings/general/components/signup_form.vue'; +import { mockData } from '../mock_data'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +describe('Signup Form', () => { + let wrapper; + let formSubmitSpy; + + const mountComponent = ({ injectedProps = {}, mountFn = shallowMount, stubs = {} } = {}) => { + wrapper = extendedWrapper( + mountFn(SignupForm, { + provide: { + ...mockData, + ...injectedProps, + }, + stubs, + }), + ); + }; + + const queryByLabelText = (text) => within(wrapper.element).queryByLabelText(text); + + const findForm = () => wrapper.findByTestId('form'); + const findInputCsrf = () => findForm().find('[name="authenticity_token"]'); + const findFormSubmitButton = () => findForm().find(GlButton); + + const findDenyListRawRadio = () => queryByLabelText('Enter denylist manually'); + const findDenyListFileRadio = () => queryByLabelText('Upload denylist file'); + + const findDenyListRawInputGroup = () => wrapper.findByTestId('domain-denylist-raw-input-group'); + const findDenyListFileInputGroup = () => wrapper.findByTestId('domain-denylist-file-input-group'); + + const findRequireAdminApprovalCheckbox = () => + wrapper.findByTestId('require-admin-approval-checkbox'); + const findUserCapInput = () => wrapper.findByTestId('user-cap-input'); + const findModal = () => wrapper.find(GlModal); + + afterEach(() => { + wrapper.destroy(); + + formSubmitSpy = null; + }); + + describe('form data', () => { + beforeEach(() => { + mountComponent(); + }); + + it.each` + prop | propValue | elementSelector | formElementPassedDataType | formElementKey | expected + ${'signupEnabled'} | ${mockData.signupEnabled} | ${'[name="application_setting[signup_enabled]"]'} | ${'prop'} | ${'value'} | ${mockData.signupEnabled} + ${'requireAdminApprovalAfterUserSignup'} | ${mockData.requireAdminApprovalAfterUserSignup} | ${'[name="application_setting[require_admin_approval_after_user_signup]"]'} | ${'prop'} | ${'value'} | ${mockData.requireAdminApprovalAfterUserSignup} + ${'sendUserConfirmationEmail'} | ${mockData.sendUserConfirmationEmail} | ${'[name="application_setting[send_user_confirmation_email]"]'} | ${'prop'} | ${'value'} | ${mockData.sendUserConfirmationEmail} + ${'newUserSignupsCap'} | ${mockData.newUserSignupsCap} | ${'[name="application_setting[new_user_signups_cap]"]'} | ${'attribute'} | ${'value'} | ${mockData.newUserSignupsCap} + ${'minimumPasswordLength'} | ${mockData.minimumPasswordLength} | ${'[name="application_setting[minimum_password_length]"]'} | ${'attribute'} | ${'value'} | ${mockData.minimumPasswordLength} + ${'minimumPasswordLengthMin'} | ${mockData.minimumPasswordLengthMin} | ${'[name="application_setting[minimum_password_length]"]'} | ${'attribute'} | ${'min'} | ${mockData.minimumPasswordLengthMin} + ${'minimumPasswordLengthMax'} | ${mockData.minimumPasswordLengthMax} | ${'[name="application_setting[minimum_password_length]"]'} | ${'attribute'} | ${'max'} | ${mockData.minimumPasswordLengthMax} + ${'domainAllowlistRaw'} | ${mockData.domainAllowlistRaw} | ${'[name="application_setting[domain_allowlist_raw]"]'} | ${'value'} | ${'value'} | ${mockData.domainAllowlistRaw} + ${'domainDenylistEnabled'} | ${mockData.domainDenylistEnabled} | ${'[name="application_setting[domain_denylist_enabled]"]'} | ${'prop'} | ${'value'} | ${mockData.domainDenylistEnabled} + ${'denylistTypeRawSelected'} | ${mockData.denylistTypeRawSelected} | ${'[name="denylist_type"]'} | ${'attribute'} | ${'checked'} | ${'raw'} + ${'domainDenylistRaw'} | ${mockData.domainDenylistRaw} | ${'[name="application_setting[domain_denylist_raw]"]'} | ${'value'} | ${'value'} | ${mockData.domainDenylistRaw} + ${'emailRestrictionsEnabled'} | ${mockData.emailRestrictionsEnabled} | ${'[name="application_setting[email_restrictions_enabled]"]'} | ${'prop'} | ${'value'} | ${mockData.emailRestrictionsEnabled} + ${'emailRestrictions'} | ${mockData.emailRestrictions} | ${'[name="application_setting[email_restrictions]"]'} | ${'value'} | ${'value'} | ${mockData.emailRestrictions} + ${'afterSignUpText'} | ${mockData.afterSignUpText} | ${'[name="application_setting[after_sign_up_text]"]'} | ${'value'} | ${'value'} | ${mockData.afterSignUpText} + `( + 'form element $elementSelector gets $expected value for $formElementKey $formElementPassedDataType when prop $prop is set to $propValue', + ({ elementSelector, expected, formElementKey, formElementPassedDataType }) => { + const formElement = wrapper.find(elementSelector); + + switch (formElementPassedDataType) { + case 'attribute': + expect(formElement.attributes(formElementKey)).toBe(expected); + break; + case 'prop': + expect(formElement.props(formElementKey)).toBe(expected); + break; + case 'value': + expect(formElement.element.value).toBe(expected); + break; + default: + expect(formElement.props(formElementKey)).toBe(expected); + break; + } + }, + ); + it('gets passed the path for action attribute', () => { + expect(findForm().attributes('action')).toBe(mockData.settingsPath); + }); + + it('gets passed the csrf token as a hidden input value', () => { + expect(findInputCsrf().attributes('type')).toBe('hidden'); + + expect(findInputCsrf().attributes('value')).toBe('mock-csrf-token'); + }); + }); + + describe('domain deny list', () => { + describe('when it is set to raw from props', () => { + beforeEach(() => { + mountComponent({ mountFn: mount }); + }); + + it('has raw list selected', () => { + expect(findDenyListRawRadio().checked).toBe(true); + }); + + it('has file not selected', () => { + expect(findDenyListFileRadio().checked).toBe(false); + }); + + it('raw list input is displayed', () => { + expect(findDenyListRawInputGroup().exists()).toBe(true); + }); + + it('file input is not displayed', () => { + expect(findDenyListFileInputGroup().exists()).toBe(false); + }); + + describe('when user clicks on file radio', () => { + beforeEach(() => { + fireEvent.click(findDenyListFileRadio()); + }); + + it('has raw list not selected', () => { + expect(findDenyListRawRadio().checked).toBe(false); + }); + + it('has file selected', () => { + expect(findDenyListFileRadio().checked).toBe(true); + }); + + it('raw list input is not displayed', () => { + expect(findDenyListRawInputGroup().exists()).toBe(false); + }); + + it('file input is displayed', () => { + expect(findDenyListFileInputGroup().exists()).toBe(true); + }); + }); + }); + + describe('when it is set to file from injected props', () => { + beforeEach(() => { + mountComponent({ mountFn: mount, injectedProps: { denylistTypeRawSelected: false } }); + }); + + it('has raw list not selected', () => { + expect(findDenyListRawRadio().checked).toBe(false); + }); + + it('has file selected', () => { + expect(findDenyListFileRadio().checked).toBe(true); + }); + + it('raw list input is not displayed', () => { + expect(findDenyListRawInputGroup().exists()).toBe(false); + }); + + it('file input is displayed', () => { + expect(findDenyListFileInputGroup().exists()).toBe(true); + }); + + describe('when user clicks on raw list radio', () => { + beforeEach(() => { + fireEvent.click(findDenyListRawRadio()); + }); + + it('has raw list selected', () => { + expect(findDenyListRawRadio().checked).toBe(true); + }); + + it('has file not selected', () => { + expect(findDenyListFileRadio().checked).toBe(false); + }); + + it('raw list input is displayed', () => { + expect(findDenyListRawInputGroup().exists()).toBe(true); + }); + + it('file input is not displayed', () => { + expect(findDenyListFileInputGroup().exists()).toBe(false); + }); + }); + }); + }); + + describe('form submit button confirmation modal for side-effect of adding possibly unwanted new users', () => { + it.each` + requireAdminApprovalAction | userCapAction | buttonEffect + ${'unchanged from true'} | ${'unchanged'} | ${'submits form'} + ${'unchanged from false'} | ${'unchanged'} | ${'submits form'} + ${'toggled off'} | ${'unchanged'} | ${'shows confirmation modal'} + ${'toggled on'} | ${'unchanged'} | ${'submits form'} + ${'unchanged from false'} | ${'increased'} | ${'shows confirmation modal'} + ${'unchanged from true'} | ${'increased'} | ${'shows confirmation modal'} + ${'toggled off'} | ${'increased'} | ${'shows confirmation modal'} + ${'toggled on'} | ${'increased'} | ${'shows confirmation modal'} + ${'toggled on'} | ${'decreased'} | ${'submits form'} + ${'unchanged from false'} | ${'changed from limited to unlimited'} | ${'shows confirmation modal'} + ${'unchanged from false'} | ${'changed from unlimited to limited'} | ${'submits form'} + ${'unchanged from false'} | ${'unchanged from unlimited'} | ${'submits form'} + `( + '$buttonEffect if require admin approval for new sign-ups is $requireAdminApprovalAction and the user cap is $userCapAction', + async ({ requireAdminApprovalAction, userCapAction, buttonEffect }) => { + let isModalDisplayed; + + switch (buttonEffect) { + case 'shows confirmation modal': + isModalDisplayed = true; + break; + case 'submits form': + isModalDisplayed = false; + break; + default: + isModalDisplayed = false; + break; + } + + const isFormSubmittedWhenClickingFormSubmitButton = !isModalDisplayed; + + const injectedProps = {}; + + const USER_CAP_DEFAULT = 5; + + switch (userCapAction) { + case 'changed from unlimited to limited': + injectedProps.newUserSignupsCap = ''; + break; + case 'unchanged from unlimited': + injectedProps.newUserSignupsCap = ''; + break; + default: + injectedProps.newUserSignupsCap = USER_CAP_DEFAULT; + break; + } + + switch (requireAdminApprovalAction) { + case 'unchanged from true': + injectedProps.requireAdminApprovalAfterUserSignup = true; + break; + case 'unchanged from false': + injectedProps.requireAdminApprovalAfterUserSignup = false; + break; + case 'toggled off': + injectedProps.requireAdminApprovalAfterUserSignup = true; + break; + case 'toggled on': + injectedProps.requireAdminApprovalAfterUserSignup = false; + break; + default: + injectedProps.requireAdminApprovalAfterUserSignup = false; + break; + } + + formSubmitSpy = jest.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(); + + await mountComponent({ + injectedProps, + stubs: { GlButton, GlModal: stubComponent(GlModal) }, + }); + + findModal().vm.show = jest.fn(); + + if ( + requireAdminApprovalAction === 'toggled off' || + requireAdminApprovalAction === 'toggled on' + ) { + await findRequireAdminApprovalCheckbox().vm.$emit('input', false); + } + + switch (userCapAction) { + case 'increased': + await findUserCapInput().vm.$emit('input', USER_CAP_DEFAULT + 1); + break; + case 'decreased': + await findUserCapInput().vm.$emit('input', USER_CAP_DEFAULT - 1); + break; + case 'changed from limited to unlimited': + await findUserCapInput().vm.$emit('input', ''); + break; + case 'changed from unlimited to limited': + await findUserCapInput().vm.$emit('input', USER_CAP_DEFAULT); + break; + default: + break; + } + + await findFormSubmitButton().trigger('click'); + + if (isFormSubmittedWhenClickingFormSubmitButton) { + expect(formSubmitSpy).toHaveBeenCalled(); + expect(findModal().vm.show).not.toHaveBeenCalled(); + } else { + expect(formSubmitSpy).not.toHaveBeenCalled(); + expect(findModal().vm.show).toHaveBeenCalled(); + } + }, + ); + + describe('modal actions', () => { + beforeEach(async () => { + const INITIAL_USER_CAP = 5; + + await mountComponent({ + injectedProps: { + newUserSignupsCap: INITIAL_USER_CAP, + }, + stubs: { GlButton, GlModal: stubComponent(GlModal) }, + }); + + await findUserCapInput().vm.$emit('input', INITIAL_USER_CAP + 1); + + await findFormSubmitButton().trigger('click'); + }); + + it('submits the form after clicking approve users button', async () => { + formSubmitSpy = jest.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(); + + await findModal().vm.$emit('primary'); + + expect(formSubmitSpy).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/admin/signup_restrictions/mock_data.js b/spec/frontend/admin/signup_restrictions/mock_data.js new file mode 100644 index 00000000000..624a5614c9c --- /dev/null +++ b/spec/frontend/admin/signup_restrictions/mock_data.js @@ -0,0 +1,41 @@ +export const rawMockData = { + host: 'path/to/host', + settingsPath: 'path/to/settings', + signupEnabled: 'true', + requireAdminApprovalAfterUserSignup: 'true', + sendUserConfirmationEmail: 'true', + minimumPasswordLength: '8', + minimumPasswordLengthMin: '3', + minimumPasswordLengthMax: '10', + minimumPasswordLengthHelpLink: 'help/link', + domainAllowlistRaw: 'domain1.com, domain2.com', + newUserSignupsCap: '8', + domainDenylistEnabled: 'true', + denylistTypeRawSelected: 'true', + domainDenylistRaw: 'domain2.com, domain3.com', + emailRestrictionsEnabled: 'true', + supportedSyntaxLinkUrl: '/supported/syntax/link', + emailRestrictions: 'user1@domain.com, user2@domain.com', + afterSignUpText: 'Congratulations on your successful sign-up!', +}; + +export const mockData = { + host: 'path/to/host', + settingsPath: 'path/to/settings', + signupEnabled: true, + requireAdminApprovalAfterUserSignup: true, + sendUserConfirmationEmail: true, + minimumPasswordLength: '8', + minimumPasswordLengthMin: '3', + minimumPasswordLengthMax: '10', + minimumPasswordLengthHelpLink: 'help/link', + domainAllowlistRaw: 'domain1.com, domain2.com', + newUserSignupsCap: '8', + domainDenylistEnabled: true, + denylistTypeRawSelected: true, + domainDenylistRaw: 'domain2.com, domain3.com', + emailRestrictionsEnabled: true, + supportedSyntaxLinkUrl: '/supported/syntax/link', + emailRestrictions: 'user1@domain.com, user2@domain.com', + afterSignUpText: 'Congratulations on your successful sign-up!', +}; diff --git a/spec/frontend/admin/signup_restrictions/utils.js b/spec/frontend/admin/signup_restrictions/utils.js new file mode 100644 index 00000000000..30a95467e09 --- /dev/null +++ b/spec/frontend/admin/signup_restrictions/utils.js @@ -0,0 +1,19 @@ +export const setDataAttributes = (data, element) => { + Object.keys(data).forEach((key) => { + const value = data[key]; + + // attribute should be: + // - valueless if value is 'true' + // - absent if value is 'false' + switch (value) { + case false: + break; + case true: + element.dataset[`${key}`] = ''; + break; + default: + element.dataset[`${key}`] = value; + break; + } + }); +}; diff --git a/spec/frontend/admin/signup_restrictions/utils_spec.js b/spec/frontend/admin/signup_restrictions/utils_spec.js new file mode 100644 index 00000000000..fd5c4c3317b --- /dev/null +++ b/spec/frontend/admin/signup_restrictions/utils_spec.js @@ -0,0 +1,22 @@ +import { getParsedDataset } from '~/pages/admin/application_settings/utils'; +import { rawMockData, mockData } from './mock_data'; + +describe('utils', () => { + describe('getParsedDataset', () => { + it('returns correct results', () => { + expect( + getParsedDataset({ + dataset: rawMockData, + booleanAttributes: [ + 'signupEnabled', + 'requireAdminApprovalAfterUserSignup', + 'sendUserConfirmationEmail', + 'domainDenylistEnabled', + 'denylistTypeRawSelected', + 'emailRestrictionsEnabled', + ], + }), + ).toEqual(mockData); + }); + }); +}); diff --git a/spec/frontend/admin/users/components/user_date_spec.js b/spec/frontend/admin/users/components/user_date_spec.js index 6428b10059b..1a2f2938db5 100644 --- a/spec/frontend/admin/users/components/user_date_spec.js +++ b/spec/frontend/admin/users/components/user_date_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import UserDate from '~/admin/users/components/user_date.vue'; +import UserDate from '~/vue_shared/components/user_date.vue'; import { users } from '../mock_data'; const mockDate = users[0].createdAt; diff --git a/spec/frontend/admin/users/components/users_table_spec.js b/spec/frontend/admin/users/components/users_table_spec.js index f1fcc20fb65..424b0deebd3 100644 --- a/spec/frontend/admin/users/components/users_table_spec.js +++ b/spec/frontend/admin/users/components/users_table_spec.js @@ -3,8 +3,8 @@ import { mount } from '@vue/test-utils'; import AdminUserActions from '~/admin/users/components/user_actions.vue'; import AdminUserAvatar from '~/admin/users/components/user_avatar.vue'; -import AdminUserDate from '~/admin/users/components/user_date.vue'; import AdminUsersTable from '~/admin/users/components/users_table.vue'; +import AdminUserDate from '~/vue_shared/components/user_date.vue'; import { users, paths } from '../mock_data'; diff --git a/spec/frontend/admin/users/new_spec.js b/spec/frontend/admin/users/new_spec.js new file mode 100644 index 00000000000..692c583dca8 --- /dev/null +++ b/spec/frontend/admin/users/new_spec.js @@ -0,0 +1,76 @@ +import { + setupInternalUserRegexHandler, + ID_USER_EMAIL, + ID_USER_EXTERNAL, + ID_WARNING, +} from '~/admin/users/new'; + +describe('admin/users/new', () => { + const FIXTURE = 'admin/users/new_with_internal_user_regex.html'; + + let elExternal; + let elUserEmail; + let elWarningMessage; + + beforeEach(() => { + loadFixtures(FIXTURE); + setupInternalUserRegexHandler(); + + elExternal = document.getElementById(ID_USER_EXTERNAL); + elUserEmail = document.getElementById(ID_USER_EMAIL); + elWarningMessage = document.getElementById(ID_WARNING); + + elExternal.checked = true; + }); + + const changeEmail = (val) => { + elUserEmail.value = val; + elUserEmail.dispatchEvent(new Event('input')); + }; + + const hasHiddenWarning = () => elWarningMessage.classList.contains('hidden'); + + describe('Behaviour of userExternal checkbox', () => { + it('hides warning by default', () => { + expect(hasHiddenWarning()).toBe(true); + }); + + describe('when matches email as internal', () => { + beforeEach(() => { + changeEmail('test@'); + }); + + it('has external unchecked', () => { + expect(elExternal.checked).toBe(false); + }); + + it('shows warning', () => { + expect(hasHiddenWarning()).toBe(false); + }); + + describe('when external is checked again', () => { + beforeEach(() => { + elExternal.dispatchEvent(new Event('change')); + }); + + it('hides warning', () => { + expect(hasHiddenWarning()).toBe(true); + }); + }); + }); + + describe('when matches emails as external', () => { + beforeEach(() => { + changeEmail('test.ext@'); + }); + + it('has external checked', () => { + expect(elExternal.checked).toBe(true); + }); + + it('hides warning', () => { + expect(hasHiddenWarning()).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/alerts_settings/components/__snapshots__/alerts_settings_form_spec.js.snap b/spec/frontend/alerts_settings/components/__snapshots__/alerts_settings_form_spec.js.snap deleted file mode 100644 index 1f8429af7dd..00000000000 --- a/spec/frontend/alerts_settings/components/__snapshots__/alerts_settings_form_spec.js.snap +++ /dev/null @@ -1,524 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`AlertsSettingsForm with default values renders the initial template 1`] = ` -<form - class="gl-mt-6" -> - <div - class="tabs gl-tabs" - id="__BVID__6" - > - <!----> - <div - class="" - > - <ul - class="nav gl-tabs-nav" - id="__BVID__6__BV_tab_controls_" - role="tablist" - > - <!----> - <li - class="nav-item" - role="presentation" - > - <a - aria-controls="__BVID__8" - aria-posinset="1" - aria-selected="true" - aria-setsize="3" - class="nav-link active gl-tab-nav-item gl-tab-nav-item-active gl-tab-nav-item-active-indigo" - href="#" - id="__BVID__8___BV_tab_button__" - role="tab" - target="_self" - > - Configure details - </a> - </li> - <li - class="nav-item" - role="presentation" - > - <a - aria-controls="__BVID__19" - aria-disabled="true" - aria-posinset="2" - aria-selected="false" - aria-setsize="3" - class="nav-link disabled disabled gl-tab-nav-item" - href="#" - id="__BVID__19___BV_tab_button__" - role="tab" - tabindex="-1" - target="_self" - > - View credentials - </a> - </li> - <li - class="nav-item" - role="presentation" - > - <a - aria-controls="__BVID__41" - aria-disabled="true" - aria-posinset="3" - aria-selected="false" - aria-setsize="3" - class="nav-link disabled disabled gl-tab-nav-item" - href="#" - id="__BVID__41___BV_tab_button__" - role="tab" - tabindex="-1" - target="_self" - > - Send test alert - </a> - </li> - <!----> - </ul> - </div> - <div - class="tab-content gl-tab-content" - id="__BVID__6__BV_tab_container_" - > - <transition-stub - css="true" - enteractiveclass="" - enterclass="" - entertoclass="show" - leaveactiveclass="" - leaveclass="show" - leavetoclass="" - mode="out-in" - name="" - > - <div - aria-hidden="false" - aria-labelledby="__BVID__8___BV_tab_button__" - class="tab-pane active" - id="__BVID__8" - role="tabpanel" - style="" - > - <div - class="form-group gl-form-group" - id="integration-type" - role="group" - > - <label - class="d-block col-form-label" - for="integration-type" - id="integration-type__BV_label_" - > - 1.Select integration type - </label> - <div - class="bv-no-focus-ring" - > - <select - class="gl-form-select gl-max-w-full custom-select" - id="__BVID__13" - > - <option - value="" - > - Select integration type - </option> - <option - value="HTTP" - > - HTTP Endpoint - </option> - <option - value="PROMETHEUS" - > - External Prometheus - </option> - </select> - - <!----> - <!----> - <!----> - <!----> - </div> - </div> - - <div - class="gl-mt-3" - > - <!----> - - <label - class="gl-display-flex gl-flex-direction-column gl-mb-0 gl-w-max-content gl-my-4 gl-font-weight-normal" - > - <span - class="gl-toggle-wrapper" - > - <span - class="gl-toggle-label" - data-testid="toggle-label" - > - Active - </span> - - <!----> - - <button - aria-label="Active" - class="gl-toggle" - role="switch" - type="button" - > - <span - class="toggle-icon" - > - <svg - aria-hidden="true" - class="gl-icon s16" - data-testid="close-icon" - > - <use - href="#close" - /> - </svg> - </span> - </button> - </span> - - <!----> - </label> - - <!----> - - <!----> - </div> - - <div - class="gl-display-flex gl-justify-content-start gl-py-3" - > - <button - class="btn js-no-auto-disable btn-confirm btn-md gl-button" - data-testid="integration-form-submit" - type="submit" - > - <!----> - - <!----> - - <span - class="gl-button-text" - > - - Save integration - - </span> - </button> - - <button - class="btn gl-ml-3 js-no-auto-disable btn-default btn-md gl-button" - type="reset" - > - <!----> - - <!----> - - <span - class="gl-button-text" - > - Cancel and close - </span> - </button> - </div> - </div> - </transition-stub> - - <transition-stub - css="true" - enteractiveclass="" - enterclass="" - entertoclass="show" - leaveactiveclass="" - leaveclass="show" - leavetoclass="" - mode="out-in" - name="" - > - <div - aria-hidden="true" - aria-labelledby="__BVID__19___BV_tab_button__" - class="tab-pane disabled" - id="__BVID__19" - role="tabpanel" - style="display: none;" - > - <span> - Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the - <a - class="gl-link gl-display-inline-block" - href="https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html" - rel="noopener noreferrer" - target="_blank" - > - GitLab documentation - </a> - to learn more about configuring your endpoint. - </span> - - <fieldset - class="form-group gl-form-group" - id="integration-webhook" - > - <!----> - <div - class="bv-no-focus-ring" - role="group" - tabindex="-1" - > - <div - class="gl-my-4" - > - <span - class="gl-font-weight-bold" - > - - Webhook URL - - </span> - - <div - id="url" - readonly="readonly" - > - <div - class="input-group" - role="group" - > - <!----> - <!----> - - <input - class="gl-form-input form-control" - id="url" - readonly="readonly" - type="text" - /> - - <div - class="input-group-append" - > - <button - aria-label="Copy this value" - class="btn gl-m-0! btn-default btn-md gl-button btn-default-secondary btn-icon" - data-clipboard-text="" - title="Copy" - type="button" - > - <!----> - - <svg - aria-hidden="true" - class="gl-button-icon gl-icon s16" - data-testid="copy-to-clipboard-icon" - > - <use - href="#copy-to-clipboard" - /> - </svg> - - <!----> - </button> - </div> - <!----> - </div> - </div> - </div> - - <div - class="gl-my-4" - > - <span - class="gl-font-weight-bold" - > - - Authorization key - - </span> - - <div - class="gl-mb-3" - id="authorization-key" - readonly="readonly" - > - <div - class="input-group" - role="group" - > - <!----> - <!----> - - <input - class="gl-form-input form-control" - id="authorization-key" - readonly="readonly" - type="text" - /> - - <div - class="input-group-append" - > - <button - aria-label="Copy this value" - class="btn gl-m-0! btn-default btn-md gl-button btn-default-secondary btn-icon" - data-clipboard-text="" - title="Copy" - type="button" - > - <!----> - - <svg - aria-hidden="true" - class="gl-button-icon gl-icon s16" - data-testid="copy-to-clipboard-icon" - > - <use - href="#copy-to-clipboard" - /> - </svg> - - <!----> - </button> - </div> - <!----> - </div> - </div> - </div> - <!----> - <!----> - <!----> - </div> - </fieldset> - - <button - class="btn btn-danger btn-md disabled gl-button" - disabled="disabled" - type="button" - > - <!----> - - <!----> - - <span - class="gl-button-text" - > - - Reset Key - - </span> - </button> - - <button - class="btn gl-ml-3 js-no-auto-disable btn-default btn-md gl-button" - type="reset" - > - <!----> - - <!----> - - <span - class="gl-button-text" - > - Cancel and close - </span> - </button> - - <!----> - </div> - </transition-stub> - - <transition-stub - css="true" - enteractiveclass="" - enterclass="" - entertoclass="show" - leaveactiveclass="" - leaveclass="show" - leavetoclass="" - mode="out-in" - name="" - > - <div - aria-hidden="true" - aria-labelledby="__BVID__41___BV_tab_button__" - class="tab-pane disabled" - id="__BVID__41" - role="tabpanel" - style="display: none;" - > - <fieldset - class="form-group gl-form-group" - id="test-integration" - > - <!----> - <div - class="bv-no-focus-ring" - role="group" - tabindex="-1" - > - <span> - Provide an example payload from the monitoring tool you intend to integrate with. This will allow you to send an alert to an active GitLab alerting point. - </span> - - <textarea - class="gl-form-input gl-form-textarea gl-my-3 form-control is-valid" - id="test-payload" - placeholder="{ \\"events\\": [{ \\"application\\": \\"Name of application\\" }] }" - style="resize: none; overflow-y: scroll;" - wrap="soft" - /> - <!----> - <!----> - <!----> - </div> - </fieldset> - - <button - class="btn js-no-auto-disable btn-confirm btn-md gl-button" - data-testid="send-test-alert" - type="button" - > - <!----> - - <!----> - - <span - class="gl-button-text" - > - - Send - - </span> - </button> - - <button - class="btn gl-ml-3 js-no-auto-disable btn-default btn-md gl-button" - type="reset" - > - <!----> - - <!----> - - <span - class="gl-button-text" - > - Cancel and close - </span> - </button> - </div> - </transition-stub> - <!----> - </div> - </div> -</form> -`; diff --git a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js index d2dcff14432..9912ac433a5 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js @@ -1,5 +1,7 @@ import { GlForm, GlFormSelect, GlFormInput, GlToggle, GlFormTextarea, GlTab } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import MappingBuilder from '~/alerts_settings/components/alert_mapping_builder.vue'; import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue'; @@ -8,48 +10,52 @@ import alertFields from '../mocks/alert_fields.json'; import parsedMapping from '../mocks/parsed_mapping.json'; import { defaultAlertSettingsConfig } from './util'; +const scrollIntoViewMock = jest.fn(); +HTMLElement.prototype.scrollIntoView = scrollIntoViewMock; + describe('AlertsSettingsForm', () => { let wrapper; const mockToastShow = jest.fn(); const createComponent = ({ data = {}, props = {}, multiIntegrations = true } = {}) => { - wrapper = mount(AlertsSettingsForm, { - data() { - return { ...data }; - }, - propsData: { - loading: false, - canAddIntegration: true, - ...props, - }, - provide: { - ...defaultAlertSettingsConfig, - multiIntegrations, - }, - mocks: { - $apollo: { - query: jest.fn(), + wrapper = extendedWrapper( + mount(AlertsSettingsForm, { + data() { + return { ...data }; }, - $toast: { - show: mockToastShow, + propsData: { + loading: false, + canAddIntegration: true, + ...props, }, - }, - }); + provide: { + ...defaultAlertSettingsConfig, + multiIntegrations, + }, + mocks: { + $apollo: { + query: jest.fn(), + }, + $toast: { + show: mockToastShow, + }, + }, + }), + ); }; const findForm = () => wrapper.findComponent(GlForm); const findSelect = () => wrapper.findComponent(GlFormSelect); const findFormFields = () => wrapper.findAllComponents(GlFormInput); const findFormToggle = () => wrapper.findComponent(GlToggle); - const findSamplePayloadSection = () => wrapper.find('[data-testid="sample-payload-section"]'); - const findMappingBuilderSection = () => wrapper.find(`[id = "mapping-builder"]`); + const findSamplePayloadSection = () => wrapper.findByTestId('sample-payload-section'); const findMappingBuilder = () => wrapper.findComponent(MappingBuilder); - const findSubmitButton = () => wrapper.find(`[type = "submit"]`); - const findMultiSupportText = () => - wrapper.find(`[data-testid="multi-integrations-not-supported"]`); - const findJsonTestSubmit = () => wrapper.find(`[data-testid="send-test-alert"]`); + + const findSubmitButton = () => wrapper.findByTestId('integration-form-submit'); + const findMultiSupportText = () => wrapper.findByTestId('multi-integrations-not-supported'); + const findJsonTestSubmit = () => wrapper.findByTestId('send-test-alert'); const findJsonTextArea = () => wrapper.find(`[id = "test-payload"]`); - const findActionBtn = () => wrapper.find(`[data-testid="payload-action-btn"]`); + const findActionBtn = () => wrapper.findByTestId('payload-action-btn'); const findTabs = () => wrapper.findAllComponents(GlTab); afterEach(() => { @@ -74,10 +80,6 @@ describe('AlertsSettingsForm', () => { createComponent(); }); - it('renders the initial template', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - it('render the initial form with only an integration type dropdown', () => { expect(findForm().exists()).toBe(true); expect(findSelect().exists()).toBe(true); @@ -151,29 +153,28 @@ describe('AlertsSettingsForm', () => { findMappingBuilder().vm.$emit('onMappingUpdate', sampleMapping); findForm().trigger('submit'); - expect(wrapper.emitted('create-new-integration')[0]).toEqual([ - { - type: typeSet.http, - variables: { - name: integrationName, - active: true, - payloadAttributeMappings: sampleMapping, - payloadExample: '{}', - }, + expect(wrapper.emitted('create-new-integration')[0][0]).toMatchObject({ + type: typeSet.http, + variables: { + name: integrationName, + active: true, + payloadAttributeMappings: sampleMapping, + payloadExample: '{}', }, - ]); + }); }); it('update', () => { createComponent({ data: { - selectedIntegration: typeSet.http, - currentIntegration: { id: '1', name: 'Test integration pre' }, + integrationForm: { id: '1', name: 'Test integration pre', type: typeSet.http }, + currentIntegration: { id: '1' }, }, props: { loading: false, }, }); + const updatedIntegrationName = 'Test integration post'; enableIntegration(0, updatedIntegrationName); @@ -181,21 +182,16 @@ describe('AlertsSettingsForm', () => { expect(submitBtn.exists()).toBe(true); expect(submitBtn.text()).toBe('Save integration'); - findForm().trigger('submit'); - - expect(wrapper.emitted('update-integration')[0]).toEqual( - expect.arrayContaining([ - { - type: typeSet.http, - variables: { - name: updatedIntegrationName, - active: true, - payloadAttributeMappings: [], - payloadExample: '{}', - }, - }, - ]), - ); + submitBtn.trigger('click'); + expect(wrapper.emitted('update-integration')[0][0]).toMatchObject({ + type: typeSet.http, + variables: { + name: updatedIntegrationName, + active: true, + payloadAttributeMappings: [], + payloadExample: '{}', + }, + }); }); }); @@ -211,16 +207,17 @@ describe('AlertsSettingsForm', () => { findForm().trigger('submit'); - expect(wrapper.emitted('create-new-integration')[0]).toEqual([ - { type: typeSet.prometheus, variables: { apiUrl, active: true } }, - ]); + expect(wrapper.emitted('create-new-integration')[0][0]).toMatchObject({ + type: typeSet.prometheus, + variables: { apiUrl, active: true }, + }); }); it('update', () => { createComponent({ data: { - selectedIntegration: typeSet.prometheus, - currentIntegration: { id: '1', apiUrl: 'https://test-pre.com' }, + integrationForm: { id: '1', apiUrl: 'https://test-pre.com', type: typeSet.prometheus }, + currentIntegration: { id: '1' }, }, props: { loading: false, @@ -236,9 +233,10 @@ describe('AlertsSettingsForm', () => { findForm().trigger('submit'); - expect(wrapper.emitted('update-integration')[0]).toEqual([ - { type: typeSet.prometheus, variables: { apiUrl, active: true } }, - ]); + expect(wrapper.emitted('update-integration')[0][0]).toMatchObject({ + type: typeSet.prometheus, + variables: { apiUrl, active: true }, + }); }); }); }); @@ -247,7 +245,6 @@ describe('AlertsSettingsForm', () => { beforeEach(() => { createComponent({ data: { - selectedIntegration: typeSet.http, currentIntegration: { id: '1', name: 'Test' }, active: true, }, @@ -262,7 +259,7 @@ describe('AlertsSettingsForm', () => { await findJsonTextArea().setValue('Invalid JSON'); jest.runAllTimers(); - await wrapper.vm.$nextTick(); + await nextTick(); const jsonTestSubmit = findJsonTestSubmit(); expect(jsonTestSubmit.exists()).toBe(true); @@ -275,7 +272,7 @@ describe('AlertsSettingsForm', () => { await findJsonTextArea().setValue('{ "value": "value" }'); jest.runAllTimers(); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findJsonTestSubmit().props('disabled')).toBe(false); }); }); @@ -283,14 +280,13 @@ describe('AlertsSettingsForm', () => { describe('Test payload section for HTTP integration', () => { const validSamplePayload = JSON.stringify(alertFields); const emptySamplePayload = '{}'; - beforeEach(() => { createComponent({ + multiIntegrations: true, data: { + integrationForm: { type: typeSet.http }, currentIntegration: { - type: typeSet.http, - payloadExample: validSamplePayload, - payloadAttributeMappings: [], + payloadExample: emptySamplePayload, }, active: false, resetPayloadAndMappingConfirmed: false, @@ -300,25 +296,25 @@ describe('AlertsSettingsForm', () => { }); describe.each` - active | resetPayloadAndMappingConfirmed | disabled - ${true} | ${true} | ${undefined} - ${false} | ${true} | ${'disabled'} - ${true} | ${false} | ${'disabled'} - ${false} | ${false} | ${'disabled'} - `('', ({ active, resetPayloadAndMappingConfirmed, disabled }) => { + payload | resetPayloadAndMappingConfirmed | disabled + ${validSamplePayload} | ${true} | ${undefined} + ${emptySamplePayload} | ${true} | ${undefined} + ${validSamplePayload} | ${false} | ${'disabled'} + ${emptySamplePayload} | ${false} | ${undefined} + `('', ({ payload, resetPayloadAndMappingConfirmed, disabled }) => { const payloadResetMsg = resetPayloadAndMappingConfirmed ? 'was confirmed' : 'was not confirmed'; const enabledState = disabled === 'disabled' ? 'disabled' : 'enabled'; - const activeState = active ? 'active' : 'not active'; + const validPayloadMsg = payload === emptySamplePayload ? 'not valid' : 'valid'; - it(`textarea should be ${enabledState} when payload reset ${payloadResetMsg} and current integration is ${activeState}`, async () => { + it(`textarea should be ${enabledState} when payload reset ${payloadResetMsg} and payload is ${validPayloadMsg}`, async () => { wrapper.setData({ - selectedIntegration: typeSet.http, - active, + currentIntegration: { payloadExample: payload }, resetPayloadAndMappingConfirmed, }); - await wrapper.vm.$nextTick(); + + await nextTick(); expect(findSamplePayloadSection().find(GlFormTextarea).attributes('disabled')).toBe( disabled, ); @@ -329,9 +325,9 @@ describe('AlertsSettingsForm', () => { describe.each` resetPayloadAndMappingConfirmed | payloadExample | caption ${false} | ${validSamplePayload} | ${'Edit payload'} - ${true} | ${emptySamplePayload} | ${'Parse payload for custom mapping'} - ${true} | ${validSamplePayload} | ${'Parse payload for custom mapping'} - ${false} | ${emptySamplePayload} | ${'Parse payload for custom mapping'} + ${true} | ${emptySamplePayload} | ${'Parse payload fields'} + ${true} | ${validSamplePayload} | ${'Parse payload fields'} + ${false} | ${emptySamplePayload} | ${'Parse payload fields'} `('', ({ resetPayloadAndMappingConfirmed, payloadExample, caption }) => { const samplePayloadMsg = payloadExample ? 'was provided' : 'was not provided'; const payloadResetMsg = resetPayloadAndMappingConfirmed @@ -340,16 +336,12 @@ describe('AlertsSettingsForm', () => { it(`shows ${caption} button when sample payload ${samplePayloadMsg} and payload reset ${payloadResetMsg}`, async () => { wrapper.setData({ - selectedIntegration: typeSet.http, currentIntegration: { payloadExample, - type: typeSet.http, - active: true, - payloadAttributeMappings: [], }, resetPayloadAndMappingConfirmed, }); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findActionBtn().text()).toBe(caption); }); }); @@ -358,7 +350,6 @@ describe('AlertsSettingsForm', () => { describe('Parsing payload', () => { beforeEach(() => { wrapper.setData({ - selectedIntegration: typeSet.http, resetPayloadAndMappingConfirmed: true, }); }); @@ -398,11 +389,12 @@ describe('AlertsSettingsForm', () => { ${true} | ${false} | ${1} | ${false} ${false} | ${true} | ${1} | ${false} `('', ({ alertFieldsProvided, multiIntegrations, integrationOption, visible }) => { - const visibleMsg = visible ? 'is rendered' : 'is not rendered'; - const alertFieldsMsg = alertFieldsProvided ? 'are provided' : 'are not provided'; + const visibleMsg = visible ? 'rendered' : 'not rendered'; + const alertFieldsMsg = alertFieldsProvided ? 'provided' : 'not provided'; const integrationType = integrationOption === 1 ? typeSet.http : typeSet.prometheus; + const multiIntegrationsEnabled = multiIntegrations ? 'enabled' : 'not enabled'; - it(`${visibleMsg} when integration type is ${integrationType} and alert fields ${alertFieldsMsg}`, async () => { + it(`is ${visibleMsg} when multiIntegrations are ${multiIntegrationsEnabled}, integration type is ${integrationType} and alert fields are ${alertFieldsMsg}`, async () => { createComponent({ multiIntegrations, props: { @@ -411,8 +403,80 @@ describe('AlertsSettingsForm', () => { }); await selectOptionAtIndex(integrationOption); - expect(findMappingBuilderSection().exists()).toBe(visible); + expect(findMappingBuilder().exists()).toBe(visible); + }); + }); + }); + + describe('Form validation', () => { + beforeEach(() => { + createComponent(); + }); + + it('should not be able to submit when no integration type is selected', async () => { + await selectOptionAtIndex(0); + + expect(findSubmitButton().attributes('disabled')).toBe('disabled'); + }); + + it('should not be able to submit when HTTP integration form is invalid', async () => { + await selectOptionAtIndex(1); + await findFormFields().at(0).vm.$emit('input', ''); + expect(findSubmitButton().attributes('disabled')).toBe('disabled'); + }); + + it('should be able to submit when HTTP integration form is valid', async () => { + await selectOptionAtIndex(1); + await findFormFields().at(0).vm.$emit('input', 'Name'); + expect(findSubmitButton().attributes('disabled')).toBe(undefined); + }); + + it('should not be able to submit when Prometheus integration form is invalid', async () => { + await selectOptionAtIndex(2); + await findFormFields().at(0).vm.$emit('input', ''); + + expect(findSubmitButton().attributes('disabled')).toBe('disabled'); + }); + + it('should be able to submit when Prometheus integration form is valid', async () => { + await selectOptionAtIndex(2); + await findFormFields().at(0).vm.$emit('input', 'http://valid.url'); + + expect(findSubmitButton().attributes('disabled')).toBe(undefined); + }); + + it('should be able to submit when form is dirty', async () => { + wrapper.setData({ + currentIntegration: { type: typeSet.http, name: 'Existing integration' }, + }); + await nextTick(); + await findFormFields().at(0).vm.$emit('input', 'Updated name'); + + expect(findSubmitButton().attributes('disabled')).toBe(undefined); + }); + + it('should not be able to submit when form is pristine', async () => { + wrapper.setData({ + currentIntegration: { type: typeSet.http, name: 'Existing integration' }, }); + await nextTick(); + + expect(findSubmitButton().attributes('disabled')).toBe('disabled'); + }); + + it('should disable submit button after click on validation failure', async () => { + await selectOptionAtIndex(1); + findSubmitButton().trigger('click'); + await nextTick(); + + expect(findSubmitButton().attributes('disabled')).toBe('disabled'); + }); + + it('should scroll to invalid field on validation failure', async () => { + await selectOptionAtIndex(1); + findSubmitButton().trigger('click'); + + expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' }); }); }); }); diff --git a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js index 77fac6dd022..dd8ce838dfd 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js @@ -1,18 +1,18 @@ -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; +import { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql'; import updateHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import IntegrationsList from '~/alerts_settings/components/alerts_integrations_list.vue'; import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue'; -import AlertsSettingsWrapper, { - i18n, -} from '~/alerts_settings/components/alerts_settings_wrapper.vue'; -import { typeSet } from '~/alerts_settings/constants'; +import AlertsSettingsWrapper from '~/alerts_settings/components/alerts_settings_wrapper.vue'; +import { typeSet, i18n } from '~/alerts_settings/constants'; import createPrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql'; import destroyHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql'; import resetHttpTokenMutation from '~/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql'; @@ -27,10 +27,12 @@ import { RESET_INTEGRATION_TOKEN_ERROR, UPDATE_INTEGRATION_ERROR, INTEGRATION_PAYLOAD_TEST_ERROR, + INTEGRATION_INACTIVE_PAYLOAD_TEST_ERROR, DELETE_INTEGRATION_ERROR, } from '~/alerts_settings/utils/error_messages'; import createFlash, { FLASH_TYPES } from '~/flash'; import axios from '~/lib/utils/axios_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; import { createHttpVariables, updateHttpVariables, @@ -81,8 +83,9 @@ describe('AlertsSettingsWrapper', () => { const findLoader = () => wrapper.findComponent(IntegrationsList).findComponent(GlLoadingIcon); const findIntegrationsList = () => wrapper.findComponent(IntegrationsList); const findIntegrations = () => wrapper.find(IntegrationsList).findAll('table tbody tr'); - const findAddIntegrationBtn = () => wrapper.find('[data-testid="add-integration-btn"]'); + const findAddIntegrationBtn = () => wrapper.findByTestId('add-integration-btn'); const findAlertsSettingsForm = () => wrapper.findComponent(AlertsSettingsForm); + const findAlert = () => wrapper.findComponent(GlAlert); async function destroyHttpIntegration(localWrapper) { await jest.runOnlyPendingTimers(); @@ -94,32 +97,34 @@ describe('AlertsSettingsWrapper', () => { } async function awaitApolloDomMock() { - await wrapper.vm.$nextTick(); // kick off the DOM update + await nextTick(); // kick off the DOM update await jest.runOnlyPendingTimers(); // kick off the mocked GQL stuff (promises) - await wrapper.vm.$nextTick(); // kick off the DOM update for flash + await nextTick(); // kick off the DOM update for flash } const createComponent = ({ data = {}, provide = {}, loading = false } = {}) => { - wrapper = mount(AlertsSettingsWrapper, { - data() { - return { ...data }; - }, - provide: { - ...defaultAlertSettingsConfig, - ...provide, - }, - mocks: { - $apollo: { - mutate: jest.fn(), - query: jest.fn(), - queries: { - integrations: { - loading, + wrapper = extendedWrapper( + mount(AlertsSettingsWrapper, { + data() { + return { ...data }; + }, + provide: { + ...defaultAlertSettingsConfig, + ...provide, + }, + mocks: { + $apollo: { + mutate: jest.fn(), + query: jest.fn(), + queries: { + integrations: { + loading, + }, }, }, }, - }, - }); + }), + ); }; function createComponentWithApollo({ @@ -200,20 +205,29 @@ describe('AlertsSettingsWrapper', () => { loading: false, }); }); - it('calls `$apollo.mutate` with `createHttpIntegrationMutation`', () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ - data: { createHttpIntegrationMutation: { integration: { id: '1' } } }, + + describe('Create', () => { + beforeEach(() => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ + data: { httpIntegrationCreate: { integration: { id: '1' }, errors: [] } }, + }); + findAlertsSettingsForm().vm.$emit('create-new-integration', { + type: typeSet.http, + variables: createHttpVariables, + }); }); - findAlertsSettingsForm().vm.$emit('create-new-integration', { - type: typeSet.http, - variables: createHttpVariables, + + it('calls `$apollo.mutate` with `createHttpIntegrationMutation`', () => { + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: createHttpIntegrationMutation, + update: expect.anything(), + variables: createHttpVariables, + }); }); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: createHttpIntegrationMutation, - update: expect.anything(), - variables: createHttpVariables, + it('shows success alert', () => { + expect(findAlert().exists()).toBe(true); }); }); @@ -334,13 +348,29 @@ describe('AlertsSettingsWrapper', () => { expect(createFlash).toHaveBeenCalledWith({ message: UPDATE_INTEGRATION_ERROR }); }); - it('shows an error alert when integration test payload fails ', async () => { - const mock = new AxiosMockAdapter(axios); - mock.onPost(/(.*)/).replyOnce(403); - return wrapper.vm.testAlertPayload({ endpoint: '', data: '', token: '' }).then(() => { + describe('Test alert failure', () => { + let mock; + beforeEach(() => { + mock = new AxiosMockAdapter(axios); + }); + afterEach(() => { + mock.restore(); + }); + + it('shows an error alert when integration test payload is invalid ', async () => { + mock.onPost(/(.*)/).replyOnce(httpStatusCodes.UNPROCESSABLE_ENTITY); + await wrapper.vm.testAlertPayload({ endpoint: '', data: '', token: '' }); expect(createFlash).toHaveBeenCalledWith({ message: INTEGRATION_PAYLOAD_TEST_ERROR }); expect(createFlash).toHaveBeenCalledTimes(1); - mock.restore(); + }); + + it('shows an error alert when integration is not activated ', async () => { + mock.onPost(/(.*)/).replyOnce(httpStatusCodes.FORBIDDEN); + await wrapper.vm.testAlertPayload({ endpoint: '', data: '', token: '' }); + expect(createFlash).toHaveBeenCalledWith({ + message: INTEGRATION_INACTIVE_PAYLOAD_TEST_ERROR, + }); + expect(createFlash).toHaveBeenCalledTimes(1); }); }); @@ -354,7 +384,7 @@ describe('AlertsSettingsWrapper', () => { loading: false, }); - jest.spyOn(wrapper.vm.$apollo, 'mutate'); + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValueOnce({}); findIntegrationsList().vm.$emit('edit-integration', updateHttpVariables); expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ mutation: updateCurrentHttpIntegrationMutation, @@ -372,7 +402,7 @@ describe('AlertsSettingsWrapper', () => { loading: false, }); - jest.spyOn(wrapper.vm.$apollo, 'mutate'); + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(); findIntegrationsList().vm.$emit('edit-integration', updatePrometheusVariables); expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ mutation: updateCurrentPrometheusIntegrationMutation, @@ -414,7 +444,7 @@ describe('AlertsSettingsWrapper', () => { createComponentWithApollo(); await jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findIntegrations()).toHaveLength(4); }); @@ -426,7 +456,7 @@ describe('AlertsSettingsWrapper', () => { expect(destroyIntegrationHandler).toHaveBeenCalled(); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findIntegrations()).toHaveLength(3); }); diff --git a/spec/frontend/analytics/usage_trends/components/app_spec.js b/spec/frontend/analytics/usage_trends/components/app_spec.js index f0306ea72e3..156be26f895 100644 --- a/spec/frontend/analytics/usage_trends/components/app_spec.js +++ b/spec/frontend/analytics/usage_trends/components/app_spec.js @@ -24,7 +24,7 @@ describe('UsageTrendsApp', () => { expect(wrapper.find(UsageCounts).exists()).toBe(true); }); - ['Total projects & groups', 'Pipelines', 'Issues & Merge Requests'].forEach((usage) => { + ['Total projects & groups', 'Pipelines', 'Issues & merge requests'].forEach((usage) => { it(`displays the ${usage} chart`, () => { const chartTitles = wrapper .findAll(UsageTrendsCountChart) diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index d6e1b170dd3..cb29dab86bf 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -264,18 +264,18 @@ describe('Api', () => { it('fetches group labels', (done) => { const options = { params: { search: 'foo' } }; const expectedGroup = 'gitlab-org'; - const expectedUrl = `${dummyUrlRoot}/groups/${expectedGroup}/-/labels`; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${expectedGroup}/labels`; mock.onGet(expectedUrl).reply(httpStatus.OK, [ { id: 1, - title: 'Foo Label', + name: 'Foo Label', }, ]); Api.groupLabels(expectedGroup, options) .then((res) => { expect(res.length).toBe(1); - expect(res[0].title).toBe('Foo Label'); + expect(res[0].name).toBe('Foo Label'); }) .then(done) .catch(done.fail); @@ -593,7 +593,7 @@ describe('Api', () => { }); describe('newLabel', () => { - it('creates a new label', (done) => { + it('creates a new project label', (done) => { const namespace = 'some namespace'; const project = 'some project'; const labelData = { some: 'data' }; @@ -618,26 +618,23 @@ describe('Api', () => { }); }); - it('creates a group label', (done) => { + it('creates a new group label', (done) => { const namespace = 'group/subgroup'; - const labelData = { some: 'data' }; + const labelData = { name: 'Foo', color: '#000000' }; const expectedUrl = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespace); - const expectedData = { - label: labelData, - }; mock.onPost(expectedUrl).reply((config) => { - expect(config.data).toBe(JSON.stringify(expectedData)); + expect(config.data).toBe(JSON.stringify({ color: labelData.color })); return [ httpStatus.OK, { - name: 'test', + ...labelData, }, ]; }); Api.newLabel(namespace, undefined, labelData, (response) => { - expect(response.name).toBe('test'); + expect(response.name).toBe('Foo'); done(); }); }); @@ -1225,13 +1222,26 @@ describe('Api', () => { )}/repository/files/${encodeURIComponent(dummyFilePath)}/raw`; describe('when the raw file is successfully fetched', () => { - it('resolves the Promise', () => { + beforeEach(() => { mock.onGet(expectedUrl).replyOnce(httpStatus.OK); + }); + it('resolves the Promise', () => { return Api.getRawFile(dummyProjectPath, dummyFilePath).then(() => { expect(mock.history.get).toHaveLength(1); }); }); + + describe('when the method is called with params', () => { + it('sets the params on the request', () => { + const params = { ref: 'main' }; + jest.spyOn(axios, 'get'); + + Api.getRawFile(dummyProjectPath, dummyFilePath, params); + + expect(axios.get).toHaveBeenCalledWith(expectedUrl, { params }); + }); + }); }); describe('when an error occurs while getting a raw file', () => { @@ -1382,6 +1392,38 @@ describe('Api', () => { }); }); + describe('updateFreezePeriod', () => { + const options = { + id: 10, + freeze_start: '* * * * *', + freeze_end: '* * * * *', + cron_timezone: 'America/Juneau', + created_at: '2020-07-11T07:04:50.153Z', + updated_at: '2020-07-11T07:04:50.153Z', + }; + const projectId = 8; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/freeze_periods/${options.id}`; + + const expectedResult = { + id: 10, + freeze_start: '* * * * *', + freeze_end: '* * * * *', + cron_timezone: 'America/Juneau', + created_at: '2020-07-11T07:04:50.153Z', + updated_at: '2020-07-11T07:04:50.153Z', + }; + + describe('when the freeze period is successfully updated', () => { + it('resolves the Promise', () => { + mock.onPut(expectedUrl, options).replyOnce(httpStatus.OK, expectedResult); + + return Api.updateFreezePeriod(projectId, options).then(({ data }) => { + expect(data).toStrictEqual(expectedResult); + }); + }); + }); + }); + describe('createPipeline', () => { it('creates new pipeline', () => { const redirectUrl = 'ci-project/-/pipelines/95'; diff --git a/spec/frontend/batch_comments/components/preview_item_spec.js b/spec/frontend/batch_comments/components/preview_item_spec.js index 08167a94068..03a28ce8001 100644 --- a/spec/frontend/batch_comments/components/preview_item_spec.js +++ b/spec/frontend/batch_comments/components/preview_item_spec.js @@ -124,4 +124,16 @@ describe('Batch comments draft preview item component', () => { ); }); }); + + describe('for new comment', () => { + it('renders title', () => { + createComponent(false, {}, (store) => { + store.state.notes.discussions.push({}); + }); + + expect(vm.$el.querySelector('.review-preview-item-header-text').textContent).toContain( + 'Your new comment', + ); + }); + }); }); diff --git a/spec/frontend/behaviors/markdown/render_mermaid_spec.js b/spec/frontend/behaviors/markdown/render_mermaid_spec.js new file mode 100644 index 00000000000..51a345cab0e --- /dev/null +++ b/spec/frontend/behaviors/markdown/render_mermaid_spec.js @@ -0,0 +1,25 @@ +import { initMermaid } from '~/behaviors/markdown/render_mermaid'; +import * as ColorUtils from '~/lib/utils/color_utils'; + +describe('Render mermaid diagrams for Gitlab Flavoured Markdown', () => { + it.each` + darkMode | expectedTheme + ${false} | ${'neutral'} + ${true} | ${'dark'} + `('is $darkMode $expectedTheme', async ({ darkMode, expectedTheme }) => { + jest.spyOn(ColorUtils, 'darkModeEnabled').mockImplementation(() => darkMode); + + const mermaid = { + initialize: jest.fn(), + }; + + await initMermaid(mermaid); + + expect(mermaid.initialize).toHaveBeenCalledTimes(1); + expect(mermaid.initialize).toHaveBeenCalledWith( + expect.objectContaining({ + theme: expectedTheme, + }), + ); + }); +}); diff --git a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js index 26d38b115b6..bb3b16b4c7a 100644 --- a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js +++ b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js @@ -329,7 +329,7 @@ describe('ShortcutsIssuable', () => { window.shortcut = new ShortcutsIssuable(); [sidebarCollapsedBtn, sidebarExpandedBtn] = document.querySelectorAll( - '.sidebar-source-branch button', + '.js-sidebar-source-branch button', ); [sidebarCollapsedBtn, sidebarExpandedBtn].forEach((btn) => jest.spyOn(btn, 'click')); diff --git a/spec/frontend/blob/file_template_selector_spec.js b/spec/frontend/blob/file_template_selector_spec.js new file mode 100644 index 00000000000..2ab3b3ebc82 --- /dev/null +++ b/spec/frontend/blob/file_template_selector_spec.js @@ -0,0 +1,61 @@ +import $ from 'jquery'; +import FileTemplateSelector from '~/blob/file_template_selector'; + +describe('FileTemplateSelector', () => { + let subject; + let dropdown; + let wrapper; + + const createSubject = () => { + subject = new FileTemplateSelector({}); + subject.config = { + dropdown, + wrapper, + }; + subject.initDropdown = jest.fn(); + }; + + afterEach(() => { + subject = null; + }); + + describe('show method', () => { + beforeEach(() => { + dropdown = document.createElement('div'); + wrapper = document.createElement('div'); + wrapper.classList.add('hidden'); + createSubject(); + }); + + it('calls init on first call', () => { + jest.spyOn(subject, 'init'); + subject.show(); + + expect(subject.init).toHaveBeenCalledTimes(1); + }); + + it('does not call init on subsequent calls', () => { + jest.spyOn(subject, 'init'); + subject.show(); + subject.show(); + + expect(subject.init).toHaveBeenCalledTimes(1); + }); + + it('removes hidden class from $wrapper', () => { + expect($(wrapper).hasClass('hidden')).toBe(true); + + subject.show(); + + expect($(wrapper).hasClass('hidden')).toBe(false); + }); + + it('sets the focus on the dropdown', async () => { + subject.show(); + jest.spyOn(subject.$dropdown, 'focus'); + jest.runAllTimers(); + + expect(subject.$dropdown.focus).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/boards/board_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js index 4487fc15de6..36043b09636 100644 --- a/spec/frontend/boards/board_card_inner_spec.js +++ b/spec/frontend/boards/board_card_inner_spec.js @@ -1,11 +1,14 @@ import { GlLabel } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { range } from 'lodash'; +import Vuex from 'vuex'; +import BoardBlockedIcon from '~/boards/components/board_blocked_icon.vue'; import BoardCardInner from '~/boards/components/board_card_inner.vue'; +import { issuableTypes } from '~/boards/constants'; import eventHub from '~/boards/eventhub'; import defaultStore from '~/boards/stores'; import { updateHistory } from '~/lib/utils/url_utility'; -import { mockLabelList } from './mock_data'; +import { mockLabelList, mockIssue } from './mock_data'; jest.mock('~/lib/utils/url_utility'); jest.mock('~/boards/eventhub'); @@ -29,8 +32,28 @@ describe('Board card component', () => { let wrapper; let issue; let list; + let store; + + const findBoardBlockedIcon = () => wrapper.find(BoardBlockedIcon); + + const createStore = () => { + store = new Vuex.Store({ + ...defaultStore, + state: { + ...defaultStore.state, + issuableType: issuableTypes.issue, + }, + getters: { + isGroupBoard: () => true, + isEpicBoard: () => false, + isProjectBoard: () => false, + }, + }); + }; + + const createWrapper = (props = {}) => { + createStore(); - const createWrapper = (props = {}, store = defaultStore) => { wrapper = mount(BoardCardInner, { store, propsData: { @@ -41,6 +64,13 @@ describe('Board card component', () => { stubs: { GlLabel: true, }, + mocks: { + $apollo: { + queries: { + blockingIssuables: { loading: false }, + }, + }, + }, provide: { rootPath: '/', scopedLabelsAvailable: false, @@ -51,14 +81,9 @@ describe('Board card component', () => { beforeEach(() => { list = mockLabelList; issue = { - title: 'Testing', - id: 1, - iid: 1, - confidential: false, + ...mockIssue, labels: [list.label], assignees: [], - referencePath: '#1', - webUrl: '/test/1', weight: 1, }; @@ -68,6 +93,7 @@ describe('Board card component', () => { afterEach(() => { wrapper.destroy(); wrapper = null; + store = null; jest.clearAllMocks(); }); @@ -87,18 +113,38 @@ describe('Board card component', () => { 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 issue ID with #', () => { - expect(wrapper.find('.board-card-number').text()).toContain(`#${issue.id}`); + expect(wrapper.find('.board-card-number').text()).toContain(`#${issue.iid}`); }); it('does not render assignee', () => { expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(false); }); + describe('blocked', () => { + it('renders blocked icon if issue is blocked', async () => { + createWrapper({ + item: { + ...issue, + blocked: true, + }, + }); + + expect(findBoardBlockedIcon().exists()).toBe(true); + }); + + it('does not show blocked icon if issue is not blocked', () => { + createWrapper({ + item: { + ...issue, + blocked: false, + }, + }); + + expect(findBoardBlockedIcon().exists()).toBe(false); + }); + }); + describe('confidential issue', () => { beforeEach(() => { wrapper.setProps({ @@ -303,21 +349,6 @@ describe('Board card component', () => { }); }); - describe('blocked', () => { - beforeEach(() => { - wrapper.setProps({ - item: { - ...wrapper.props('item'), - blocked: true, - }, - }); - }); - - it('renders blocked icon if issue is blocked', () => { - expect(wrapper.find('.issue-blocked-icon').exists()).toBe(true); - }); - }); - describe('filterByLabel method', () => { beforeEach(() => { delete window.location; diff --git a/spec/frontend/boards/board_new_issue_deprecated_spec.js b/spec/frontend/boards/board_new_issue_deprecated_spec.js index 3903ad201b2..3beaf870bf5 100644 --- a/spec/frontend/boards/board_new_issue_deprecated_spec.js +++ b/spec/frontend/boards/board_new_issue_deprecated_spec.js @@ -111,7 +111,7 @@ describe('Issue boards new issue form', () => { describe('submit success', () => { it('creates new issue', () => { - wrapper.setData({ title: 'submit issue' }); + wrapper.setData({ title: 'create issue' }); return Vue.nextTick() .then(submitIssue) @@ -122,7 +122,7 @@ describe('Issue boards new issue form', () => { it('enables button after submit', () => { jest.spyOn(wrapper.vm, 'submit').mockImplementation(); - wrapper.setData({ title: 'submit issue' }); + wrapper.setData({ title: 'create issue' }); return Vue.nextTick() .then(submitIssue) @@ -132,7 +132,7 @@ describe('Issue boards new issue form', () => { }); it('clears title after submit', () => { - wrapper.setData({ title: 'submit issue' }); + wrapper.setData({ title: 'create issue' }); return Vue.nextTick() .then(submitIssue) @@ -143,17 +143,17 @@ describe('Issue boards new issue form', () => { it('sets detail issue after submit', () => { expect(boardsStore.detail.issue.title).toBe(undefined); - wrapper.setData({ title: 'submit issue' }); + wrapper.setData({ title: 'create issue' }); return Vue.nextTick() .then(submitIssue) .then(() => { - expect(boardsStore.detail.issue.title).toBe('submit issue'); + expect(boardsStore.detail.issue.title).toBe('create issue'); }); }); it('sets detail list after submit', () => { - wrapper.setData({ title: 'submit issue' }); + wrapper.setData({ title: 'create issue' }); return Vue.nextTick() .then(submitIssue) @@ -164,7 +164,7 @@ describe('Issue boards new issue form', () => { it('sets detail weight after submit', () => { boardsStore.weightFeatureAvailable = true; - wrapper.setData({ title: 'submit issue' }); + wrapper.setData({ title: 'create issue' }); return Vue.nextTick() .then(submitIssue) @@ -175,7 +175,7 @@ describe('Issue boards new issue form', () => { it('does not set detail weight after submit', () => { boardsStore.weightFeatureAvailable = false; - wrapper.setData({ title: 'submit issue' }); + wrapper.setData({ title: 'create issue' }); return Vue.nextTick() .then(submitIssue) diff --git a/spec/frontend/boards/components/__snapshots__/board_blocked_icon_spec.js.snap b/spec/frontend/boards/components/__snapshots__/board_blocked_icon_spec.js.snap new file mode 100644 index 00000000000..c000f300e4d --- /dev/null +++ b/spec/frontend/boards/components/__snapshots__/board_blocked_icon_spec.js.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BoardBlockedIcon on mouseenter on blocked icon with more than three blocking issues matches the snapshot 1`] = ` +"<div class=\\"gl-display-inline\\"><svg data-testid=\\"issue-blocked-icon\\" aria-hidden=\\"true\\" class=\\"issue-blocked-icon gl-mr-2 gl-cursor-pointer gl-icon s16\\" id=\\"blocked-icon-uniqueId\\"> + <use href=\\"#issue-block\\"></use> + </svg> + <div class=\\"gl-popover\\"> + <ul class=\\"gl-list-style-none gl-p-0\\"> + <li><a href=\\"http://gdk.test:3000/gitlab-org/my-project-1/-/issues/6\\" class=\\"gl-link gl-text-blue-500! gl-font-sm\\">my-project-1#6</a> + <p data-testid=\\"issuable-title\\" class=\\"gl-mb-3 gl-display-block!\\"> + blocking issue title 1 + </p> + </li> + <li><a href=\\"http://gdk.test:3000/gitlab-org/my-project-1/-/issues/5\\" class=\\"gl-link gl-text-blue-500! gl-font-sm\\">my-project-1#5</a> + <p data-testid=\\"issuable-title\\" class=\\"gl-mb-3 gl-display-block!\\"> + blocking issue title 2 + blocking issue title 2 + blocking issue title 2 + bloc… + </p> + </li> + <li><a href=\\"http://gdk.test:3000/gitlab-org/my-project-1/-/issues/4\\" class=\\"gl-link gl-text-blue-500! gl-font-sm\\">my-project-1#4</a> + <p data-testid=\\"issuable-title\\" class=\\"gl-mb-3 gl-display-block!\\"> + blocking issue title 3 + </p> + </li> + </ul> + <div class=\\"gl-mt-4\\"> + <p data-testid=\\"hidden-blocking-count\\" class=\\"gl-mb-3\\">+ 1 more issue</p> <a data-testid=\\"view-all-issues\\" href=\\"http://gdk.test:3000/gitlab-org/my-project-1/-/issues/0#related-issues\\" class=\\"gl-link gl-text-blue-500! gl-font-sm\\">View all blocking issues</a> + </div><span data-testid=\\"popover-title\\">Blocked by 4 issues</span> + </div> +</div>" +`; diff --git a/spec/frontend/boards/components/board_add_new_column_form_spec.js b/spec/frontend/boards/components/board_add_new_column_form_spec.js index 3702f55f17b..3b26ca57d6f 100644 --- a/spec/frontend/boards/components/board_add_new_column_form_spec.js +++ b/spec/frontend/boards/components/board_add_new_column_form_spec.js @@ -1,6 +1,6 @@ -import { GlFormGroup, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui'; +import { GlDropdown, GlFormGroup, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; import Vuex from 'vuex'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue'; @@ -25,7 +25,7 @@ describe('Board card layout', () => { const mountComponent = ({ loading = false, - formDescription = '', + noneSelected = '', searchLabel = '', searchPlaceholder = '', selectedId, @@ -34,12 +34,9 @@ describe('Board card layout', () => { } = {}) => { wrapper = extendedWrapper( shallowMount(BoardAddNewColumnForm, { - stubs: { - GlFormGroup: true, - }, propsData: { loading, - formDescription, + noneSelected, searchLabel, searchPlaceholder, selectedId, @@ -51,13 +48,15 @@ describe('Board card layout', () => { ...actions, }, }), + stubs: { + GlDropdown, + }, }), ); }; afterEach(() => { wrapper.destroy(); - wrapper = null; }); const formTitle = () => wrapper.findByTestId('board-add-column-form-title').text(); @@ -65,10 +64,13 @@ describe('Board card layout', () => { const findSearchLabel = () => wrapper.find(GlFormGroup); const cancelButton = () => wrapper.findByTestId('cancelAddNewColumn'); const submitButton = () => wrapper.findByTestId('addNewColumnButton'); + const findDropdown = () => wrapper.findComponent(GlDropdown); it('shows form title & search input', () => { mountComponent(); + findDropdown().vm.$emit('show'); + expect(formTitle()).toEqual(BoardAddNewColumnForm.i18n.newList); expect(findSearchInput().exists()).toBe(true); }); @@ -86,16 +88,6 @@ describe('Board card layout', () => { expect(setAddColumnFormVisibility).toHaveBeenCalledWith(expect.anything(), false); }); - it('sets placeholder and description from props', () => { - const props = { - formDescription: 'Some description of a list', - }; - - mountComponent(props); - - expect(wrapper.html()).toHaveText(props.formDescription); - }); - describe('items', () => { const mountWithItems = (loading) => mountComponent({ @@ -151,13 +143,11 @@ describe('Board card layout', () => { expect(submitButton().props('disabled')).toBe(true); }); - it('emits add-list event on click', async () => { + it('emits add-list event on click', () => { mountComponent({ selectedId: mockLabelList.label.id, }); - await nextTick(); - submitButton().vm.$emit('click'); expect(wrapper.emitted('add-list')).toEqual([[]]); 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 60584eaf6cf..61f210f566b 100644 --- a/spec/frontend/boards/components/board_add_new_column_spec.js +++ b/spec/frontend/boards/components/board_add_new_column_spec.js @@ -1,3 +1,4 @@ +import { GlFormRadioGroup } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; @@ -12,6 +13,10 @@ Vue.use(Vuex); describe('Board card layout', () => { let wrapper; + const selectLabel = (id) => { + wrapper.findComponent(GlFormRadioGroup).vm.$emit('change', id); + }; + const createStore = ({ actions = {}, getters = {}, state = {} } = {}) => { return new Vuex.Store({ state: { @@ -57,6 +62,11 @@ describe('Board card layout', () => { }, }), ); + + // trigger change event + if (selectedId) { + selectLabel(selectedId); + } }; afterEach(() => { diff --git a/spec/frontend/boards/components/board_blocked_icon_spec.js b/spec/frontend/boards/components/board_blocked_icon_spec.js new file mode 100644 index 00000000000..7b04942f056 --- /dev/null +++ b/spec/frontend/boards/components/board_blocked_icon_spec.js @@ -0,0 +1,226 @@ +import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; +import Vue from 'vue'; +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 BoardBlockedIcon from '~/boards/components/board_blocked_icon.vue'; +import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants'; +import { truncate } from '~/lib/utils/text_utility'; +import { + mockIssue, + mockBlockingIssue1, + mockBlockingIssue2, + mockBlockingIssuablesResponse1, + mockBlockingIssuablesResponse2, + mockBlockingIssuablesResponse3, + mockBlockedIssue1, + mockBlockedIssue2, +} from '../mock_data'; + +describe('BoardBlockedIcon', () => { + let wrapper; + let mockApollo; + + const findGlIcon = () => wrapper.find(GlIcon); + const findGlPopover = () => wrapper.find(GlPopover); + const findGlLink = () => wrapper.find(GlLink); + const findPopoverTitle = () => wrapper.findByTestId('popover-title'); + const findIssuableTitle = () => wrapper.findByTestId('issuable-title'); + const findHiddenBlockingCount = () => wrapper.findByTestId('hidden-blocking-count'); + const findViewAllIssuableLink = () => wrapper.findByTestId('view-all-issues'); + + const waitForApollo = async () => { + jest.runOnlyPendingTimers(); + await waitForPromises(); + }; + + const mouseenter = async () => { + findGlIcon().vm.$emit('mouseenter'); + + await wrapper.vm.$nextTick(); + await waitForApollo(); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const createWrapperWithApollo = ({ + item = mockBlockedIssue1, + blockingIssuablesSpy = jest.fn().mockResolvedValue(mockBlockingIssuablesResponse1), + } = {}) => { + mockApollo = createMockApollo([ + [blockingIssuablesQueries[issuableTypes.issue].query, blockingIssuablesSpy], + ]); + + Vue.use(VueApollo); + wrapper = extendedWrapper( + mount(BoardBlockedIcon, { + apolloProvider: mockApollo, + propsData: { + item: { + ...mockIssue, + ...item, + }, + uniqueId: 'uniqueId', + issuableType: issuableTypes.issue, + }, + attachTo: document.body, + }), + ); + }; + + const createWrapper = ({ item = {}, queries = {}, data = {}, loading = false } = {}) => { + wrapper = extendedWrapper( + shallowMount(BoardBlockedIcon, { + propsData: { + item: { + ...mockIssue, + ...item, + }, + uniqueId: 'uniqueid', + issuableType: issuableTypes.issue, + }, + data() { + return { + ...data, + }; + }, + mocks: { + $apollo: { + queries: { + blockingIssuables: { loading }, + ...queries, + }, + }, + }, + stubs: { + GlPopover, + }, + attachTo: document.body, + }), + ); + }; + + it('should render blocked icon', () => { + createWrapper(); + + expect(findGlIcon().exists()).toBe(true); + }); + + it('should display a loading spinner while loading', () => { + createWrapper({ loading: true }); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + + it('should not query for blocking issuables by default', async () => { + createWrapperWithApollo(); + + expect(findGlPopover().text()).not.toContain(mockBlockingIssue1.title); + }); + + describe('on mouseenter on blocked icon', () => { + it('should query for blocking issuables and render the result', async () => { + createWrapperWithApollo(); + + expect(findGlPopover().text()).not.toContain(mockBlockingIssue1.title); + + await mouseenter(); + + expect(findGlPopover().exists()).toBe(true); + expect(findIssuableTitle().text()).toContain(mockBlockingIssue1.title); + expect(wrapper.vm.skip).toBe(true); + }); + + it('should emit "blocking-issuables-error" event on query error', async () => { + const mockError = new Error('mayday'); + createWrapperWithApollo({ blockingIssuablesSpy: jest.fn().mockRejectedValue(mockError) }); + + await mouseenter(); + + const [ + [ + { + message, + error: { networkError }, + }, + ], + ] = wrapper.emitted('blocking-issuables-error'); + expect(message).toBe('Failed to fetch blocking issues'); + expect(networkError).toBe(mockError); + }); + + describe('with a single blocking issue', () => { + beforeEach(async () => { + createWrapperWithApollo(); + + await mouseenter(); + }); + + it('should render a title of the issuable', async () => { + expect(findIssuableTitle().text()).toBe(mockBlockingIssue1.title); + }); + + it('should render issuable reference and link to the issuable', async () => { + const formattedRef = mockBlockingIssue1.reference.split('/')[1]; + + expect(findGlLink().text()).toBe(formattedRef); + expect(findGlLink().attributes('href')).toBe(mockBlockingIssue1.webUrl); + }); + + it('should render popover title with correct blocking issuable count', async () => { + expect(findPopoverTitle().text()).toBe('Blocked by 1 issue'); + }); + }); + + describe('when issue has a long title', () => { + it('should render a truncated title', async () => { + createWrapperWithApollo({ + blockingIssuablesSpy: jest.fn().mockResolvedValue(mockBlockingIssuablesResponse2), + }); + + await mouseenter(); + + const truncatedTitle = truncate( + mockBlockingIssue2.title, + wrapper.vm.$options.textTruncateWidth, + ); + expect(findIssuableTitle().text()).toBe(truncatedTitle); + }); + }); + + describe('with more than three blocking issues', () => { + beforeEach(async () => { + createWrapperWithApollo({ + item: mockBlockedIssue2, + blockingIssuablesSpy: jest.fn().mockResolvedValue(mockBlockingIssuablesResponse3), + }); + + await mouseenter(); + }); + + it('matches the snapshot', () => { + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('should render popover title with correct blocking issuable count', async () => { + expect(findPopoverTitle().text()).toBe('Blocked by 4 issues'); + }); + + it('should render the number of hidden blocking issuables', () => { + expect(findHiddenBlockingCount().text()).toBe('+ 1 more issue'); + }); + + it('should link to the blocked issue page at the related issue anchor', async () => { + expect(findViewAllIssuableLink().text()).toBe('View all blocking issues'); + expect(findViewAllIssuableLink().attributes('href')).toBe( + `${mockBlockedIssue2.webUrl}#related-issues`, + ); + }); + }); + }); +}); diff --git a/spec/frontend/boards/components/board_content_sidebar_spec.js b/spec/frontend/boards/components/board_content_sidebar_spec.js new file mode 100644 index 00000000000..7f949739891 --- /dev/null +++ b/spec/frontend/boards/components/board_content_sidebar_spec.js @@ -0,0 +1,140 @@ +import { GlDrawer } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { stubComponent } from 'helpers/stub_component'; +import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue'; +import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue'; +import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; +import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue'; +import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue'; +import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; +import { ISSUABLE } from '~/boards/constants'; +import { mockIssue, mockIssueGroupPath, mockIssueProjectPath } from '../mock_data'; + +describe('BoardContentSidebar', () => { + let wrapper; + let store; + + const createStore = ({ mockGetters = {}, mockActions = {} } = {}) => { + store = new Vuex.Store({ + state: { + sidebarType: ISSUABLE, + issues: { [mockIssue.id]: { ...mockIssue, epic: null } }, + activeId: mockIssue.id, + issuableType: 'issue', + }, + getters: { + activeBoardItem: () => { + return { ...mockIssue, epic: null }; + }, + groupPathForActiveIssue: () => mockIssueGroupPath, + projectPathForActiveIssue: () => mockIssueProjectPath, + isSidebarOpen: () => true, + ...mockGetters, + }, + actions: mockActions, + }); + }; + + const createComponent = () => { + /* + Dynamically imported components (in our case ee imports) + aren't stubbed automatically in VTU v1: + https://github.com/vuejs/vue-test-utils/issues/1279. + + This requires us to additionally mock apollo or vuex stores. + */ + wrapper = shallowMount(BoardContentSidebar, { + provide: { + canUpdate: true, + rootPath: '/', + groupId: 1, + }, + store, + stubs: { + GlDrawer: stubComponent(GlDrawer, { + template: '<div><slot name="header"></slot><slot></slot></div>', + }), + }, + mocks: { + $apollo: { + queries: { + participants: { + loading: false, + }, + currentIteration: { + loading: false, + }, + iterations: { + loading: false, + }, + }, + }, + }, + }); + }; + + beforeEach(() => { + createStore(); + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('confirms we render GlDrawer', () => { + expect(wrapper.find(GlDrawer).exists()).toBe(true); + }); + + it('does not render GlDrawer when isSidebarOpen is false', () => { + createStore({ mockGetters: { isSidebarOpen: () => false } }); + createComponent(); + + expect(wrapper.find(GlDrawer).exists()).toBe(false); + }); + + it('applies an open attribute', () => { + expect(wrapper.find(GlDrawer).props('open')).toBe(true); + }); + + it('renders BoardSidebarLabelsSelect', () => { + expect(wrapper.find(BoardSidebarLabelsSelect).exists()).toBe(true); + }); + + it('renders BoardSidebarTitle', () => { + expect(wrapper.find(BoardSidebarTitle).exists()).toBe(true); + }); + + it('renders BoardSidebarDueDate', () => { + expect(wrapper.find(BoardSidebarDueDate).exists()).toBe(true); + }); + + it('renders BoardSidebarSubscription', () => { + expect(wrapper.find(BoardSidebarSubscription).exists()).toBe(true); + }); + + it('renders BoardSidebarMilestoneSelect', () => { + expect(wrapper.find(BoardSidebarMilestoneSelect).exists()).toBe(true); + }); + + describe('when we emit close', () => { + let toggleBoardItem; + + beforeEach(() => { + toggleBoardItem = jest.fn(); + createStore({ mockActions: { toggleBoardItem } }); + createComponent(); + }); + + it('calls toggleBoardItem with correct parameters', async () => { + wrapper.find(GlDrawer).vm.$emit('close'); + + expect(toggleBoardItem).toHaveBeenCalledTimes(1); + expect(toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), { + boardItem: { ...mockIssue, epic: null }, + sidebarType: ISSUABLE, + }); + }); + }); +}); diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js index 159b67ccc67..8c1a7bd3947 100644 --- a/spec/frontend/boards/components/board_content_spec.js +++ b/spec/frontend/boards/components/board_content_spec.js @@ -33,7 +33,12 @@ describe('BoardContent', () => { }); }; - const createComponent = ({ state, props = {}, graphqlBoardListsEnabled = false } = {}) => { + const createComponent = ({ + state, + props = {}, + graphqlBoardListsEnabled = false, + canAdminList = true, + } = {}) => { const store = createStore({ ...defaultState, ...state, @@ -42,11 +47,11 @@ describe('BoardContent', () => { localVue, propsData: { lists: mockListsWithModel, - canAdminList: true, disabled: false, ...props, }, provide: { + canAdminList, glFeatures: { graphqlBoardLists: graphqlBoardListsEnabled }, }, store, @@ -82,7 +87,7 @@ describe('BoardContent', () => { describe('can admin list', () => { beforeEach(() => { - createComponent({ graphqlBoardListsEnabled: true, props: { canAdminList: true } }); + createComponent({ graphqlBoardListsEnabled: true, canAdminList: true }); }); it('renders draggable component', () => { @@ -92,7 +97,7 @@ describe('BoardContent', () => { describe('can not admin list', () => { beforeEach(() => { - createComponent({ graphqlBoardListsEnabled: true, props: { canAdminList: false } }); + createComponent({ graphqlBoardListsEnabled: true, canAdminList: false }); }); it('does not render draggable component', () => { diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index 32499bd5480..24fcdd528d5 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -226,7 +226,7 @@ describe('BoardForm', () => { it('passes correct primary action text and variant', () => { expect(findModalActionPrimary().text).toBe('Save changes'); - expect(findModalActionPrimary().attributes[0].variant).toBe('info'); + expect(findModalActionPrimary().attributes[0].variant).toBe('confirm'); }); it('does not render delete confirmation message', () => { diff --git a/spec/frontend/boards/components/board_new_issue_spec.js b/spec/frontend/boards/components/board_new_issue_spec.js index 737a18294bc..e6405bbcff3 100644 --- a/spec/frontend/boards/components/board_new_issue_spec.js +++ b/spec/frontend/boards/components/board_new_issue_spec.js @@ -86,7 +86,7 @@ describe('Issue boards new issue form', () => { describe('submit success', () => { it('creates new issue', async () => { - wrapper.setData({ title: 'submit issue' }); + wrapper.setData({ title: 'create issue' }); await vm.$nextTick(); await submitIssue(); @@ -95,7 +95,7 @@ describe('Issue boards new issue form', () => { it('enables button after submit', async () => { jest.spyOn(wrapper.vm, 'submit').mockImplementation(); - wrapper.setData({ title: 'submit issue' }); + wrapper.setData({ title: 'create issue' }); await vm.$nextTick(); await submitIssue(); @@ -103,7 +103,7 @@ describe('Issue boards new issue form', () => { }); it('clears title after submit', async () => { - wrapper.setData({ title: 'submit issue' }); + wrapper.setData({ title: 'create issue' }); await vm.$nextTick(); await submitIssue(); diff --git a/spec/frontend/boards/components/board_settings_sidebar_spec.js b/spec/frontend/boards/components/board_settings_sidebar_spec.js index 52b4d71f7b9..464331b6e30 100644 --- a/spec/frontend/boards/components/board_settings_sidebar_spec.js +++ b/spec/frontend/boards/components/board_settings_sidebar_spec.js @@ -4,6 +4,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import Vuex from 'vuex'; +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'; @@ -22,11 +23,18 @@ describe('BoardSettingsSidebar', () => { const labelColor = '#FFFF'; const listId = 1; - const createComponent = () => { - wrapper = shallowMount(BoardSettingsSidebar, { - store, - localVue, - }); + const findRemoveButton = () => wrapper.findByTestId('remove-list'); + + const createComponent = ({ canAdminList = false } = {}) => { + wrapper = extendedWrapper( + shallowMount(BoardSettingsSidebar, { + store, + localVue, + provide: { + canAdminList, + }, + }), + ); }; const findLabel = () => wrapper.find(GlLabel); const findDrawer = () => wrapper.find(GlDrawer); @@ -164,4 +172,29 @@ describe('BoardSettingsSidebar', () => { expect(findDrawer().exists()).toBe(false); }); }); + + it('does not render "Remove list" when user cannot admin the boards list', () => { + createComponent(); + + expect(findRemoveButton().exists()).toBe(false); + }); + + 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', () => { + expect(findRemoveButton().exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/boards/components/filtered_search_spec.js b/spec/frontend/boards/components/filtered_search_spec.js deleted file mode 100644 index 7f238aa671f..00000000000 --- a/spec/frontend/boards/components/filtered_search_spec.js +++ /dev/null @@ -1,65 +0,0 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import Vuex from 'vuex'; -import FilteredSearch from '~/boards/components/filtered_search.vue'; -import { createStore } from '~/boards/stores'; -import * as commonUtils from '~/lib/utils/common_utils'; -import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('FilteredSearch', () => { - let wrapper; - let store; - - const createComponent = () => { - wrapper = shallowMount(FilteredSearch, { - localVue, - propsData: { search: '' }, - store, - attachTo: document.body, - }); - }; - - beforeEach(() => { - // this needed for actions call for performSearch - window.gon = { features: {} }; - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('default', () => { - beforeEach(() => { - store = createStore(); - - jest.spyOn(store, 'dispatch'); - - createComponent(); - }); - - it('finds FilteredSearch', () => { - expect(wrapper.find(FilteredSearchBarRoot).exists()).toBe(true); - }); - - describe('when onFilter is emitted', () => { - it('calls performSearch', () => { - wrapper.find(FilteredSearchBarRoot).vm.$emit('onFilter', [{ value: { data: '' } }]); - - expect(store.dispatch).toHaveBeenCalledWith('performSearch'); - }); - - it('calls historyPushState', () => { - commonUtils.historyPushState = jest.fn(); - wrapper - .find(FilteredSearchBarRoot) - .vm.$emit('onFilter', [{ value: { data: 'searchQuery' } }]); - - expect(commonUtils.historyPushState).toHaveBeenCalledWith( - 'http://test.host/?search=searchQuery', - ); - }); - }); - }); -}); diff --git a/spec/frontend/boards/components/issue_time_estimate_spec.js b/spec/frontend/boards/components/issue_time_estimate_spec.js index 2e253d24125..635964b6b4a 100644 --- a/spec/frontend/boards/components/issue_time_estimate_spec.js +++ b/spec/frontend/boards/components/issue_time_estimate_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { config as vueConfig } from 'vue'; +import Vue from 'vue'; import IssueTimeEstimate from '~/boards/components/issue_time_estimate.vue'; describe('Issue Time Estimate component', () => { @@ -34,10 +34,10 @@ describe('Issue Time Estimate component', () => { try { // This will raise props validating warning by Vue, silencing it - vueConfig.silent = true; + Vue.config.silent = true; await wrapper.setProps({ estimate: 'Foo <script>alert("XSS")</script>' }); } finally { - vueConfig.silent = false; + Vue.config.silent = false; } expect(alertSpy).not.toHaveBeenCalled(); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js index 98ac211238c..153d0640b23 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js @@ -64,7 +64,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { beforeEach(async () => { createWrapper(); - jest.spyOn(wrapper.vm, 'setActiveIssueLabels').mockImplementation(() => TEST_LABELS); + jest.spyOn(wrapper.vm, 'setActiveBoardItemLabels').mockImplementation(() => TEST_LABELS); findLabelsSelect().vm.$emit('updateSelectedLabels', TEST_LABELS_PAYLOAD); store.state.boardItems[TEST_ISSUE.id].labels = TEST_LABELS; await wrapper.vm.$nextTick(); @@ -76,7 +76,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { }); it('commits change to the server', () => { - expect(wrapper.vm.setActiveIssueLabels).toHaveBeenCalledWith({ + expect(wrapper.vm.setActiveBoardItemLabels).toHaveBeenCalledWith({ addLabelIds: TEST_LABELS.map((label) => label.id), projectPath: 'gitlab-org/test-subgroup/gitlab-test', removeLabelIds: [], @@ -94,13 +94,13 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { beforeEach(async () => { createWrapper({ labels: TEST_LABELS }); - jest.spyOn(wrapper.vm, 'setActiveIssueLabels').mockImplementation(() => expectedLabels); + jest.spyOn(wrapper.vm, 'setActiveBoardItemLabels').mockImplementation(() => expectedLabels); findLabelsSelect().vm.$emit('updateSelectedLabels', testLabelsPayload); await wrapper.vm.$nextTick(); }); it('commits change to the server', () => { - expect(wrapper.vm.setActiveIssueLabels).toHaveBeenCalledWith({ + expect(wrapper.vm.setActiveBoardItemLabels).toHaveBeenCalledWith({ addLabelIds: [5, 7], removeLabelIds: [6], projectPath: 'gitlab-org/test-subgroup/gitlab-test', @@ -114,13 +114,13 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { beforeEach(async () => { createWrapper({ labels: [testLabel] }); - jest.spyOn(wrapper.vm, 'setActiveIssueLabels').mockImplementation(() => {}); + jest.spyOn(wrapper.vm, 'setActiveBoardItemLabels').mockImplementation(() => {}); }); it('commits change to the server', () => { wrapper.find(GlLabel).vm.$emit('close', testLabel); - expect(wrapper.vm.setActiveIssueLabels).toHaveBeenCalledWith({ + expect(wrapper.vm.setActiveBoardItemLabels).toHaveBeenCalledWith({ removeLabelIds: [getIdFromGraphQLId(testLabel.id)], projectPath: 'gitlab-org/test-subgroup/gitlab-test', }); @@ -131,7 +131,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { beforeEach(async () => { createWrapper({ labels: TEST_LABELS }); - jest.spyOn(wrapper.vm, 'setActiveIssueLabels').mockImplementation(() => { + jest.spyOn(wrapper.vm, 'setActiveBoardItemLabels').mockImplementation(() => { throw new Error(['failed mutation']); }); findLabelsSelect().vm.$emit('updateSelectedLabels', [{ id: '?' }]); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js index cfd7f32b2cc..7976e73ff2f 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js @@ -1,5 +1,6 @@ import { GlToggle, GlLoadingIcon } from '@gitlab/ui'; -import { mount, createLocalVue } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue'; import { createStore } from '~/boards/stores'; @@ -9,8 +10,7 @@ import { mockActiveIssue } from '../../mock_data'; jest.mock('~/flash.js'); -const localVue = createLocalVue(); -localVue.use(Vuex); +Vue.use(Vuex); describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () => { let wrapper; @@ -20,14 +20,16 @@ describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () = const findToggle = () => wrapper.find(GlToggle); const findGlLoadingIcon = () => wrapper.find(GlLoadingIcon); - const createComponent = (activeIssue = { ...mockActiveIssue }) => { + const createComponent = (activeBoardItem = { ...mockActiveIssue }) => { store = createStore(); - store.state.boardItems = { [activeIssue.id]: activeIssue }; - store.state.activeId = activeIssue.id; + store.state.boardItems = { [activeBoardItem.id]: activeBoardItem }; + store.state.activeId = activeBoardItem.id; wrapper = mount(BoardSidebarSubscription, { - localVue, store, + provide: { + emailsDisabled: false, + }, }); }; @@ -90,9 +92,9 @@ describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () = describe('Board sidebar subscription component `behavior`', () => { const mockSetActiveIssueSubscribed = (subscribedState) => { - jest.spyOn(wrapper.vm, 'setActiveIssueSubscribed').mockImplementation(async () => { - store.commit(types.UPDATE_ISSUE_BY_ID, { - issueId: mockActiveIssue.id, + jest.spyOn(wrapper.vm, 'setActiveItemSubscribed').mockImplementation(async () => { + store.commit(types.UPDATE_BOARD_ITEM_BY_ID, { + itemId: mockActiveIssue.id, prop: 'subscribed', value: subscribedState, }); @@ -110,7 +112,7 @@ describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () = await wrapper.vm.$nextTick(); expect(findGlLoadingIcon().exists()).toBe(true); - expect(wrapper.vm.setActiveIssueSubscribed).toHaveBeenCalledWith({ + expect(wrapper.vm.setActiveItemSubscribed).toHaveBeenCalledWith({ subscribed: true, projectPath: 'gitlab-org/test-subgroup/gitlab-test', }); @@ -134,7 +136,7 @@ describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () = await wrapper.vm.$nextTick(); - expect(wrapper.vm.setActiveIssueSubscribed).toHaveBeenCalledWith({ + expect(wrapper.vm.setActiveItemSubscribed).toHaveBeenCalledWith({ subscribed: false, projectPath: 'gitlab-org/test-subgroup/gitlab-test', }); @@ -148,7 +150,7 @@ describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () = it('flashes an error message when setting the subscribed state fails', async () => { createComponent(); - jest.spyOn(wrapper.vm, 'setActiveIssueSubscribed').mockImplementation(async () => { + jest.spyOn(wrapper.vm, 'setActiveItemSubscribed').mockImplementation(async () => { throw new Error(); }); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js new file mode 100644 index 00000000000..03924bfa8d3 --- /dev/null +++ b/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js @@ -0,0 +1,58 @@ +/* + To avoid duplicating tests in time_tracker.spec, + this spec only contains a simple test to check rendering. + + A detailed feature spec is used to test time tracking feature + in swimlanes sidebar. +*/ + +import { shallowMount } from '@vue/test-utils'; +import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue'; +import { createStore } from '~/boards/stores'; +import IssuableTimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue'; + +describe('BoardSidebarTimeTracker', () => { + let wrapper; + let store; + + const createComponent = (options) => { + wrapper = shallowMount(BoardSidebarTimeTracker, { + store, + ...options, + }); + }; + + beforeEach(() => { + store = createStore(); + store.state.boardItems = { + 1: { + timeEstimate: 3600, + totalTimeSpent: 1800, + humanTimeEstimate: '1h', + humanTotalTimeSpent: '30min', + }, + }; + store.state.activeId = '1'; + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it.each([[true], [false]])( + 'renders IssuableTimeTracker with correct spent and estimated time (timeTrackingLimitToHours=%s)', + (timeTrackingLimitToHours) => { + createComponent({ provide: { timeTrackingLimitToHours } }); + + expect(wrapper.find(IssuableTimeTracker).props()).toEqual({ + timeEstimate: 3600, + timeSpent: 1800, + humanTimeEstimate: '1h', + humanTimeSpent: '30min', + limitToHours: timeTrackingLimitToHours, + showCollapsed: false, + }); + }, + ); +}); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_issue_title_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js index 723d0345f76..c8ccd4c88a5 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_issue_title_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js @@ -1,11 +1,11 @@ import { GlAlert, GlFormInput, GlForm } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; -import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue'; +import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { createStore } from '~/boards/stores'; import createFlash from '~/flash'; -const TEST_TITLE = 'New issue title'; +const TEST_TITLE = 'New item title'; const TEST_ISSUE_A = { id: 'gid://gitlab/Issue/1', iid: 8, @@ -21,7 +21,7 @@ const TEST_ISSUE_B = { jest.mock('~/flash'); -describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => { +describe('~/boards/components/sidebar/board_sidebar_title.vue', () => { let wrapper; let store; @@ -32,12 +32,12 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => { wrapper = null; }); - const createWrapper = (issue = TEST_ISSUE_A) => { + const createWrapper = (item = TEST_ISSUE_A) => { store = createStore(); - store.state.boardItems = { [issue.id]: { ...issue } }; - store.dispatch('setActiveId', { id: issue.id }); + store.state.boardItems = { [item.id]: { ...item } }; + store.dispatch('setActiveId', { id: item.id }); - wrapper = shallowMount(BoardSidebarIssueTitle, { + wrapper = shallowMount(BoardSidebarTitle, { store, provide: { canUpdate: true, @@ -53,7 +53,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => { const findFormInput = () => wrapper.find(GlFormInput); const findEditableItem = () => wrapper.find(BoardEditableItem); const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]'); - const findTitle = () => wrapper.find('[data-testid="issue-title"]'); + const findTitle = () => wrapper.find('[data-testid="item-title"]'); const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]'); it('renders title and reference', () => { @@ -73,7 +73,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => { beforeEach(async () => { createWrapper(); - jest.spyOn(wrapper.vm, 'setActiveIssueTitle').mockImplementation(() => { + jest.spyOn(wrapper.vm, 'setActiveItemTitle').mockImplementation(() => { store.state.boardItems[TEST_ISSUE_A.id].title = TEST_TITLE; }); findFormInput().vm.$emit('input', TEST_TITLE); @@ -87,7 +87,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => { }); it('commits change to the server', () => { - expect(wrapper.vm.setActiveIssueTitle).toHaveBeenCalledWith({ + expect(wrapper.vm.setActiveItemTitle).toHaveBeenCalledWith({ title: TEST_TITLE, projectPath: 'h/b', }); @@ -98,14 +98,14 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => { beforeEach(async () => { createWrapper(); - jest.spyOn(wrapper.vm, 'setActiveIssueTitle').mockImplementation(() => {}); + jest.spyOn(wrapper.vm, 'setActiveItemTitle').mockImplementation(() => {}); findFormInput().vm.$emit('input', ''); findForm().vm.$emit('submit', { preventDefault: () => {} }); await wrapper.vm.$nextTick(); }); it('commits change to the server', () => { - expect(wrapper.vm.setActiveIssueTitle).not.toHaveBeenCalled(); + expect(wrapper.vm.setActiveItemTitle).not.toHaveBeenCalled(); }); }); @@ -122,7 +122,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => { it('does not collapses sidebar and shows alert', () => { expect(findCollapsed().isVisible()).toBe(false); expect(findAlert().exists()).toBe(true); - expect(localStorage.getItem(`${TEST_ISSUE_A.id}/issue-title-pending-changes`)).toBe( + expect(localStorage.getItem(`${TEST_ISSUE_A.id}/item-title-pending-changes`)).toBe( TEST_TITLE, ); }); @@ -130,7 +130,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => { describe('when accessing the form with pending changes', () => { beforeAll(() => { - localStorage.setItem(`${TEST_ISSUE_A.id}/issue-title-pending-changes`, TEST_TITLE); + localStorage.setItem(`${TEST_ISSUE_A.id}/item-title-pending-changes`, TEST_TITLE); createWrapper(); }); @@ -146,7 +146,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => { beforeEach(async () => { createWrapper(TEST_ISSUE_B); - jest.spyOn(wrapper.vm, 'setActiveIssueTitle').mockImplementation(() => { + jest.spyOn(wrapper.vm, 'setActiveItemTitle').mockImplementation(() => { store.state.boardItems[TEST_ISSUE_B.id].title = TEST_TITLE; }); findFormInput().vm.$emit('input', TEST_TITLE); @@ -155,7 +155,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => { }); it('collapses sidebar and render former title', () => { - expect(wrapper.vm.setActiveIssueTitle).not.toHaveBeenCalled(); + expect(wrapper.vm.setActiveItemTitle).not.toHaveBeenCalled(); expect(findCollapsed().isVisible()).toBe(true); expect(findTitle().text()).toBe(TEST_ISSUE_B.title); }); @@ -165,7 +165,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => { beforeEach(async () => { createWrapper(TEST_ISSUE_B); - jest.spyOn(wrapper.vm, 'setActiveIssueTitle').mockImplementation(() => { + jest.spyOn(wrapper.vm, 'setActiveItemTitle').mockImplementation(() => { throw new Error(['failed mutation']); }); findFormInput().vm.$emit('input', 'Invalid title'); @@ -173,7 +173,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => { await wrapper.vm.$nextTick(); }); - it('collapses sidebar and renders former issue title', () => { + it('collapses sidebar and renders former item title', () => { expect(findCollapsed().isVisible()).toBe(true); expect(findTitle().text()).toContain(TEST_ISSUE_B.title); expect(createFlash).toHaveBeenCalled(); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 500240d00fc..1c5b7cf8248 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -3,6 +3,7 @@ import { keyBy } from 'lodash'; import Vue from 'vue'; import '~/boards/models/list'; +import { ListType } from '~/boards/constants'; import boardsStore from '~/boards/stores/boards_store'; export const boardObj = { @@ -125,7 +126,7 @@ export const labels = [ export const rawIssue = { title: 'Issue 1', id: 'gid://gitlab/Issue/436', - iid: 27, + iid: '27', dueDate: null, timeEstimate: 0, weight: null, @@ -152,7 +153,7 @@ export const rawIssue = { export const mockIssue = { id: 'gid://gitlab/Issue/436', - iid: 27, + iid: '27', title: 'Issue 1', dueDate: null, timeEstimate: 0, @@ -398,3 +399,128 @@ export const mockActiveGroupProjects = [ { ...mockGroupProject1, archived: false }, { ...mockGroupProject2, archived: false }, ]; + +export const mockIssueGroupPath = 'gitlab-org'; +export const mockIssueProjectPath = `${mockIssueGroupPath}/gitlab-test`; + +export const mockBlockingIssue1 = { + id: 'gid://gitlab/Issue/525', + iid: '6', + title: 'blocking issue title 1', + reference: 'gitlab-org/my-project-1#6', + webUrl: 'http://gdk.test:3000/gitlab-org/my-project-1/-/issues/6', + __typename: 'Issue', +}; + +export const mockBlockingIssue2 = { + id: 'gid://gitlab/Issue/524', + iid: '5', + title: + 'blocking issue title 2 + blocking issue title 2 + blocking issue title 2 + blocking issue title 2', + reference: 'gitlab-org/my-project-1#5', + webUrl: 'http://gdk.test:3000/gitlab-org/my-project-1/-/issues/5', + __typename: 'Issue', +}; + +export const mockBlockingIssue3 = { + id: 'gid://gitlab/Issue/523', + iid: '4', + title: 'blocking issue title 3', + reference: 'gitlab-org/my-project-1#4', + webUrl: 'http://gdk.test:3000/gitlab-org/my-project-1/-/issues/4', + __typename: 'Issue', +}; + +export const mockBlockingIssue4 = { + id: 'gid://gitlab/Issue/522', + iid: '3', + title: 'blocking issue title 4', + reference: 'gitlab-org/my-project-1#3', + webUrl: 'http://gdk.test:3000/gitlab-org/my-project-1/-/issues/3', + __typename: 'Issue', +}; + +export const mockBlockingIssuablesResponse1 = { + data: { + issuable: { + __typename: 'Issue', + id: 'gid://gitlab/Issue/527', + blockingIssuables: { + __typename: 'IssueConnection', + nodes: [mockBlockingIssue1], + }, + }, + }, +}; + +export const mockBlockingIssuablesResponse2 = { + data: { + issuable: { + __typename: 'Issue', + id: 'gid://gitlab/Issue/527', + blockingIssuables: { + __typename: 'IssueConnection', + nodes: [mockBlockingIssue2], + }, + }, + }, +}; + +export const mockBlockingIssuablesResponse3 = { + data: { + issuable: { + __typename: 'Issue', + id: 'gid://gitlab/Issue/527', + blockingIssuables: { + __typename: 'IssueConnection', + nodes: [mockBlockingIssue1, mockBlockingIssue2, mockBlockingIssue3, mockBlockingIssue4], + }, + }, + }, +}; + +export const mockBlockedIssue1 = { + id: '527', + blockedByCount: 1, +}; + +export const mockBlockedIssue2 = { + id: '527', + blockedByCount: 4, + webUrl: 'http://gdk.test:3000/gitlab-org/my-project-1/-/issues/0', +}; + +export const mockMoveIssueParams = { + itemId: 1, + fromListId: 'gid://gitlab/List/1', + toListId: 'gid://gitlab/List/2', + moveBeforeId: undefined, + moveAfterId: undefined, +}; + +export const mockMoveState = { + boardLists: { + 'gid://gitlab/List/1': { + listType: ListType.backlog, + }, + 'gid://gitlab/List/2': { + listType: ListType.closed, + }, + }, + boardItems: { + [mockMoveIssueParams.itemId]: { foo: 'bar' }, + }, + boardItemsByListId: { + [mockMoveIssueParams.fromListId]: [mockMoveIssueParams.itemId], + [mockMoveIssueParams.toListId]: [], + }, +}; + +export const mockMoveData = { + reordering: false, + shouldClone: false, + itemNotInToList: true, + originalIndex: 0, + originalIssue: { foo: 'bar' }, + ...mockMoveIssueParams, +}; diff --git a/spec/frontend/boards/modal_store_spec.js b/spec/frontend/boards/modal_store_spec.js deleted file mode 100644 index 5b5ae4b6556..00000000000 --- a/spec/frontend/boards/modal_store_spec.js +++ /dev/null @@ -1,134 +0,0 @@ -/* global ListIssue */ - -import '~/boards/models/label'; -import '~/boards/models/assignee'; -import '~/boards/models/issue'; -import '~/boards/models/list'; -import Store from '~/boards/stores/modal_store'; - -describe('Modal store', () => { - let issue; - let issue2; - - beforeEach(() => { - // Set up default state - Store.store.issues = []; - Store.store.selectedIssues = []; - - issue = new ListIssue({ - title: 'Testing', - id: 1, - iid: 1, - confidential: false, - labels: [], - assignees: [], - }); - issue2 = new ListIssue({ - title: 'Testing', - id: 2, - iid: 2, - confidential: false, - labels: [], - assignees: [], - }); - Store.store.issues.push(issue); - Store.store.issues.push(issue2); - }); - - it('returns selected count', () => { - expect(Store.selectedCount()).toBe(0); - }); - - it('toggles the issue as selected', () => { - Store.toggleIssue(issue); - - expect(issue.selected).toBe(true); - expect(Store.selectedCount()).toBe(1); - }); - - it('toggles the issue as un-selected', () => { - Store.toggleIssue(issue); - Store.toggleIssue(issue); - - expect(issue.selected).toBe(false); - expect(Store.selectedCount()).toBe(0); - }); - - it('toggles all issues as selected', () => { - Store.toggleAll(); - - expect(issue.selected).toBe(true); - expect(issue2.selected).toBe(true); - expect(Store.selectedCount()).toBe(2); - }); - - it('toggles all issues as un-selected', () => { - Store.toggleAll(); - Store.toggleAll(); - - expect(issue.selected).toBe(false); - expect(issue2.selected).toBe(false); - expect(Store.selectedCount()).toBe(0); - }); - - it('toggles all if a single issue is selected', () => { - Store.toggleIssue(issue); - Store.toggleAll(); - - expect(issue.selected).toBe(true); - expect(issue2.selected).toBe(true); - expect(Store.selectedCount()).toBe(2); - }); - - it('adds issue to selected array', () => { - issue.selected = true; - Store.addSelectedIssue(issue); - - expect(Store.selectedCount()).toBe(1); - }); - - it('removes issue from selected array', () => { - Store.addSelectedIssue(issue); - Store.removeSelectedIssue(issue); - - expect(Store.selectedCount()).toBe(0); - }); - - it('returns selected issue index if present', () => { - Store.toggleIssue(issue); - - expect(Store.selectedIssueIndex(issue)).toBe(0); - }); - - it('returns -1 if issue is not selected', () => { - expect(Store.selectedIssueIndex(issue)).toBe(-1); - }); - - it('finds the selected issue', () => { - Store.toggleIssue(issue); - - expect(Store.findSelectedIssue(issue)).toBe(issue); - }); - - it('does not find a selected issue', () => { - expect(Store.findSelectedIssue(issue)).toBe(undefined); - }); - - it('does not remove from selected issue if tab is not all', () => { - Store.store.activeTab = 'selected'; - - Store.toggleIssue(issue); - Store.toggleIssue(issue); - - expect(Store.store.selectedIssues.length).toBe(1); - expect(Store.selectedCount()).toBe(0); - }); - - it('gets selected issue array with only selected issues', () => { - Store.toggleIssue(issue); - Store.toggleIssue(issue2); - Store.toggleIssue(issue2); - - expect(Store.getSelectedIssues().length).toBe(1); - }); -}); diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 69d2c8977fb..460e77a3f03 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -1,16 +1,21 @@ +import * as Sentry from '@sentry/browser'; +import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql'; import testAction from 'helpers/vuex_action_helper'; import { fullBoardId, formatListIssues, formatBoardLists, formatIssueInput, + formatIssue, + getMoveData, } from '~/boards/boards_util'; -import { inactiveId, ISSUABLE } from '~/boards/constants'; +import { inactiveId, ISSUABLE, ListType } from '~/boards/constants'; import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutation.graphql'; import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql'; -import issueMoveListMutation from '~/boards/graphql/issue_move_list.mutation.graphql'; import actions, { gqlClient } from '~/boards/stores/actions'; import * as types from '~/boards/stores/mutation_types'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + import { mockLists, mockListsById, @@ -22,6 +27,9 @@ import { labels, mockActiveIssue, mockGroupProjects, + mockMoveIssueParams, + mockMoveState, + mockMoveData, } from '../mock_data'; jest.mock('~/flash'); @@ -638,73 +646,314 @@ describe('resetIssues', () => { }); describe('moveItem', () => { - it('should dispatch moveIssue action', () => { + it('should dispatch moveIssue action with payload', () => { + const payload = { mock: 'payload' }; + testAction({ action: actions.moveItem, - expectedActions: [{ type: 'moveIssue' }], + payload, + expectedActions: [{ type: 'moveIssue', payload }], }); }); }); describe('moveIssue', () => { - const listIssues = { - 'gid://gitlab/List/1': [436, 437], - 'gid://gitlab/List/2': [], - }; - - const issues = { - 436: mockIssue, - 437: mockIssue2, - }; - - const state = { - fullPath: 'gitlab-org', - boardId: '1', - boardType: 'group', - disabled: false, - boardLists: mockLists, - boardItemsByListId: listIssues, - boardItems: issues, - }; + it('should dispatch a correct set of actions', () => { + testAction({ + action: actions.moveIssue, + payload: mockMoveIssueParams, + state: mockMoveState, + expectedActions: [ + { type: 'moveIssueCard', payload: mockMoveData }, + { type: 'updateMovedIssue', payload: mockMoveData }, + { type: 'updateIssueOrder', payload: { moveData: mockMoveData } }, + ], + }); + }); +}); - it('should commit MOVE_ISSUE mutation and MOVE_ISSUE_SUCCESS mutation when successful', (done) => { - jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ - data: { - issueMoveList: { - issue: rawIssue, - errors: [], +describe('moveIssueCard and undoMoveIssueCard', () => { + describe('card should move without clonning', () => { + let state; + let params; + let moveMutations; + let undoMutations; + + describe('when re-ordering card', () => { + beforeEach( + ({ + itemId = 123, + fromListId = 'gid://gitlab/List/1', + toListId = 'gid://gitlab/List/1', + originalIssue = { foo: 'bar' }, + originalIndex = 0, + moveBeforeId = undefined, + moveAfterId = undefined, + } = {}) => { + state = { + boardLists: { + [toListId]: { listType: ListType.backlog }, + [fromListId]: { listType: ListType.backlog }, + }, + boardItems: { [itemId]: originalIssue }, + boardItemsByListId: { [fromListId]: [123] }, + }; + params = { itemId, fromListId, toListId, moveBeforeId, moveAfterId }; + moveMutations = [ + { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: fromListId } }, + { + type: types.ADD_BOARD_ITEM_TO_LIST, + payload: { itemId, listId: toListId, moveBeforeId, moveAfterId }, + }, + ]; + undoMutations = [ + { type: types.UPDATE_BOARD_ITEM, payload: originalIssue }, + { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: fromListId } }, + { + type: types.ADD_BOARD_ITEM_TO_LIST, + payload: { itemId, listId: fromListId, atIndex: originalIndex }, + }, + ]; }, - }, + ); + + it('moveIssueCard commits a correct set of actions', () => { + testAction({ + action: actions.moveIssueCard, + state, + payload: getMoveData(state, params), + expectedMutations: moveMutations, + }); + }); + + it('undoMoveIssueCard commits a correct set of actions', () => { + testAction({ + action: actions.undoMoveIssueCard, + state, + payload: getMoveData(state, params), + expectedMutations: undoMutations, + }); + }); }); - testAction( - actions.moveIssue, - { - itemId: '436', - itemIid: mockIssue.iid, - itemPath: mockIssue.referencePath, - fromListId: 'gid://gitlab/List/1', - toListId: 'gid://gitlab/List/2', - }, - state, + describe.each([ [ + 'issue moves out of backlog', { - type: types.MOVE_ISSUE, - payload: { - originalIssue: mockIssue, - fromListId: 'gid://gitlab/List/1', - toListId: 'gid://gitlab/List/2', - }, + fromListType: ListType.backlog, + toListType: ListType.label, }, + ], + [ + 'issue card moves to closed', { - type: types.MOVE_ISSUE_SUCCESS, - payload: { issue: rawIssue }, + fromListType: ListType.label, + toListType: ListType.closed, }, ], - [], - done, - ); + [ + 'issue card moves to non-closed, non-backlog list of the same type', + { + fromListType: ListType.label, + toListType: ListType.label, + }, + ], + ])('when %s', (_, { toListType, fromListType }) => { + beforeEach( + ({ + itemId = 123, + fromListId = 'gid://gitlab/List/1', + toListId = 'gid://gitlab/List/2', + originalIssue = { foo: 'bar' }, + originalIndex = 0, + moveBeforeId = undefined, + moveAfterId = undefined, + } = {}) => { + state = { + boardLists: { + [fromListId]: { listType: fromListType }, + [toListId]: { listType: toListType }, + }, + boardItems: { [itemId]: originalIssue }, + boardItemsByListId: { [fromListId]: [123], [toListId]: [] }, + }; + params = { itemId, fromListId, toListId, moveBeforeId, moveAfterId }; + moveMutations = [ + { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: fromListId } }, + { + type: types.ADD_BOARD_ITEM_TO_LIST, + payload: { itemId, listId: toListId, moveBeforeId, moveAfterId }, + }, + ]; + undoMutations = [ + { type: types.UPDATE_BOARD_ITEM, payload: originalIssue }, + { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: toListId } }, + { + type: types.ADD_BOARD_ITEM_TO_LIST, + payload: { itemId, listId: fromListId, atIndex: originalIndex }, + }, + ]; + }, + ); + + it('moveIssueCard commits a correct set of actions', () => { + testAction({ + action: actions.moveIssueCard, + state, + payload: getMoveData(state, params), + expectedMutations: moveMutations, + }); + }); + + it('undoMoveIssueCard commits a correct set of actions', () => { + testAction({ + action: actions.undoMoveIssueCard, + state, + payload: getMoveData(state, params), + expectedMutations: undoMutations, + }); + }); + }); + }); + + describe('card should clone on move', () => { + let state; + let params; + let moveMutations; + let undoMutations; + + describe.each([ + [ + 'issue card moves to non-closed, non-backlog list of a different type', + { + fromListType: ListType.label, + toListType: ListType.assignee, + }, + ], + ])('when %s', (_, { toListType, fromListType }) => { + beforeEach( + ({ + itemId = 123, + fromListId = 'gid://gitlab/List/1', + toListId = 'gid://gitlab/List/2', + originalIssue = { foo: 'bar' }, + originalIndex = 0, + moveBeforeId = undefined, + moveAfterId = undefined, + } = {}) => { + state = { + boardLists: { + [fromListId]: { listType: fromListType }, + [toListId]: { listType: toListType }, + }, + boardItems: { [itemId]: originalIssue }, + boardItemsByListId: { [fromListId]: [123], [toListId]: [] }, + }; + params = { itemId, fromListId, toListId, moveBeforeId, moveAfterId }; + moveMutations = [ + { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: fromListId } }, + { + type: types.ADD_BOARD_ITEM_TO_LIST, + payload: { itemId, listId: toListId, moveBeforeId, moveAfterId }, + }, + { + type: types.ADD_BOARD_ITEM_TO_LIST, + payload: { itemId, listId: fromListId, atIndex: originalIndex }, + }, + ]; + undoMutations = [ + { type: types.UPDATE_BOARD_ITEM, payload: originalIssue }, + { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: fromListId } }, + { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: toListId } }, + { + type: types.ADD_BOARD_ITEM_TO_LIST, + payload: { itemId, listId: fromListId, atIndex: originalIndex }, + }, + ]; + }, + ); + + it('moveIssueCard commits a correct set of actions', () => { + testAction({ + action: actions.moveIssueCard, + state, + payload: getMoveData(state, params), + expectedMutations: moveMutations, + }); + }); + + it('undoMoveIssueCard commits a correct set of actions', () => { + testAction({ + action: actions.undoMoveIssueCard, + state, + payload: getMoveData(state, params), + expectedMutations: undoMutations, + }); + }); + }); }); +}); + +describe('updateMovedIssueCard', () => { + const label1 = { + id: 'label1', + }; + + it.each([ + [ + 'issue without a label is moved to a label list', + { + state: { + boardLists: { + from: {}, + to: { + listType: ListType.label, + label: label1, + }, + }, + boardItems: { + 1: { + labels: [], + }, + }, + }, + moveData: { + itemId: 1, + fromListId: 'from', + toListId: 'to', + }, + updatedIssue: { labels: [label1] }, + }, + ], + ])( + 'should commit UPDATE_BOARD_ITEM with a correctly updated issue data when %s', + (_, { state, moveData, updatedIssue }) => { + testAction({ + action: actions.updateMovedIssue, + payload: moveData, + state, + expectedMutations: [{ type: types.UPDATE_BOARD_ITEM, payload: updatedIssue }], + }); + }, + ); +}); + +describe('updateIssueOrder', () => { + const issues = { + 436: mockIssue, + 437: mockIssue2, + }; + + const state = { + boardItems: issues, + boardId: 'gid://gitlab/Board/1', + }; + + const moveData = { + itemId: 436, + fromListId: 'gid://gitlab/List/1', + toListId: 'gid://gitlab/List/2', + }; it('calls mutate with the correct variables', () => { const mutationVariables = { @@ -728,61 +977,56 @@ describe('moveIssue', () => { }, }); - actions.moveIssue( - { state, commit: () => {} }, - { - itemId: mockIssue.id, - itemIid: mockIssue.iid, - itemPath: mockIssue.referencePath, - fromListId: 'gid://gitlab/List/1', - toListId: 'gid://gitlab/List/2', - }, - ); + actions.updateIssueOrder({ state, commit: () => {}, dispatch: () => {} }, { moveData }); expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables); }); - it('should commit MOVE_ISSUE mutation and MOVE_ISSUE_FAILURE mutation when unsuccessful', (done) => { + it('should commit MUTATE_ISSUE_SUCCESS mutation when successful', () => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { issueMoveList: { - issue: {}, - errors: [{ foo: 'bar' }], + issue: rawIssue, + errors: [], }, }, }); testAction( - actions.moveIssue, - { - itemId: '436', - itemIid: mockIssue.iid, - itemPath: mockIssue.referencePath, - fromListId: 'gid://gitlab/List/1', - toListId: 'gid://gitlab/List/2', - }, + actions.updateIssueOrder, + { moveData }, state, [ { - type: types.MOVE_ISSUE, - payload: { - originalIssue: mockIssue, - fromListId: 'gid://gitlab/List/1', - toListId: 'gid://gitlab/List/2', - }, + type: types.MUTATE_ISSUE_SUCCESS, + payload: { issue: rawIssue }, }, + ], + [], + ); + }); + + it('should commit SET_ERROR and dispatch undoMoveIssueCard', () => { + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + issueMoveList: { + issue: {}, + errors: [{ foo: 'bar' }], + }, + }, + }); + + testAction( + actions.updateIssueOrder, + { moveData }, + state, + [ { - type: types.MOVE_ISSUE_FAILURE, - payload: { - originalIssue: mockIssue, - fromListId: 'gid://gitlab/List/1', - toListId: 'gid://gitlab/List/2', - originalIndex: 0, - }, + type: types.SET_ERROR, + payload: 'An error occurred while moving the issue. Please try again.', }, ], - [], - done, + [{ type: 'undoMoveIssueCard', payload: moveData }], ); }); }); @@ -798,11 +1042,11 @@ describe('setAssignees', () => { testAction( actions.setAssignees, [node], - { activeIssue: { iid, referencePath: refPath }, commit: () => {} }, + { activeBoardItem: { iid, referencePath: refPath }, commit: () => {} }, [ { - type: 'UPDATE_ISSUE_BY_ID', - payload: { prop: 'assignees', issueId: undefined, value: [node] }, + type: 'UPDATE_BOARD_ITEM_BY_ID', + payload: { prop: 'assignees', itemId: undefined, value: [node] }, }, ], [], @@ -812,7 +1056,43 @@ describe('setAssignees', () => { }); }); -describe('createNewIssue', () => { +describe('addListItem', () => { + it('should commit ADD_BOARD_ITEM_TO_LIST and UPDATE_BOARD_ITEM mutations', () => { + const payload = { + list: mockLists[0], + item: mockIssue, + position: 0, + }; + + testAction(actions.addListItem, payload, {}, [ + { + type: types.ADD_BOARD_ITEM_TO_LIST, + payload: { + listId: mockLists[0].id, + itemId: mockIssue.id, + atIndex: 0, + }, + }, + { type: types.UPDATE_BOARD_ITEM, payload: mockIssue }, + ]); + }); +}); + +describe('removeListItem', () => { + it('should commit REMOVE_BOARD_ITEM_FROM_LIST and REMOVE_BOARD_ITEM mutations', () => { + const payload = { + listId: mockLists[0].id, + itemId: mockIssue.id, + }; + + testAction(actions.removeListItem, payload, {}, [ + { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload }, + { type: types.REMOVE_BOARD_ITEM, payload: mockIssue.id }, + ]); + }); +}); + +describe('addListNewIssue', () => { const state = { boardType: 'group', fullPath: 'gitlab-org/gitlab', @@ -839,19 +1119,7 @@ describe('createNewIssue', () => { }, }; - it('should return issue from API on success', async () => { - jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ - data: { - createIssue: { - issue: mockIssue, - errors: [], - }, - }, - }); - - const result = await actions.createNewIssue({ state }, mockIssue); - expect(result).toEqual(mockIssue); - }); + const fakeList = { id: 'gid://gitlab/List/123' }; it('should add board scope to the issue being created', async () => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ @@ -863,7 +1131,11 @@ describe('createNewIssue', () => { }, }); - await actions.createNewIssue({ state: stateWithBoardConfig }, mockIssue); + await actions.addListNewIssue( + { dispatch: jest.fn(), commit: jest.fn(), state: stateWithBoardConfig }, + { issueInput: mockIssue, list: fakeList }, + ); + expect(gqlClient.mutate).toHaveBeenCalledWith({ mutation: issueCreateMutation, variables: { @@ -890,7 +1162,11 @@ describe('createNewIssue', () => { const payload = formatIssueInput(issue, stateWithBoardConfig.boardConfig); - await actions.createNewIssue({ state: stateWithBoardConfig }, issue); + await actions.addListNewIssue( + { dispatch: jest.fn(), commit: jest.fn(), state: stateWithBoardConfig }, + { issueInput: issue, list: fakeList }, + ); + expect(gqlClient.mutate).toHaveBeenCalledWith({ mutation: issueCreateMutation, variables: { @@ -901,51 +1177,92 @@ describe('createNewIssue', () => { expect(payload.assigneeIds).toEqual(['gid://gitlab/User/1', 'gid://gitlab/User/2']); }); - it('should commit CREATE_ISSUE_FAILURE mutation when API returns an error', (done) => { - jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ - data: { - createIssue: { - issue: mockIssue, - errors: [{ foo: 'bar' }], + describe('when issue creation mutation request succeeds', () => { + it('dispatches a correct set of mutations', () => { + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + createIssue: { + issue: mockIssue, + errors: [], + }, }, - }, + }); + + testAction({ + action: actions.addListNewIssue, + payload: { + issueInput: mockIssue, + list: fakeList, + placeholderId: 'tmp', + }, + state, + expectedActions: [ + { + type: 'addListItem', + payload: { + list: fakeList, + item: formatIssue({ ...mockIssue, id: 'tmp' }), + position: 0, + }, + }, + { type: 'removeListItem', payload: { listId: fakeList.id, itemId: 'tmp' } }, + { + type: 'addListItem', + payload: { + list: fakeList, + item: formatIssue({ ...mockIssue, id: getIdFromGraphQLId(mockIssue.id) }), + position: 0, + }, + }, + ], + }); }); - - const payload = mockIssue; - - testAction( - actions.createNewIssue, - payload, - state, - [{ type: types.CREATE_ISSUE_FAILURE }], - [], - done, - ); }); -}); - -describe('addListIssue', () => { - it('should commit ADD_ISSUE_TO_LIST mutation', (done) => { - const payload = { - list: mockLists[0], - issue: mockIssue, - position: 0, - }; - testAction( - actions.addListIssue, - payload, - {}, - [{ type: types.ADD_ISSUE_TO_LIST, payload }], - [], - done, - ); + describe('when issue creation mutation request fails', () => { + it('dispatches a correct set of mutations', () => { + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + createIssue: { + issue: mockIssue, + errors: [{ foo: 'bar' }], + }, + }, + }); + + testAction({ + action: actions.addListNewIssue, + payload: { + issueInput: mockIssue, + list: fakeList, + placeholderId: 'tmp', + }, + state, + expectedActions: [ + { + type: 'addListItem', + payload: { + list: fakeList, + item: formatIssue({ ...mockIssue, id: 'tmp' }), + position: 0, + }, + }, + { type: 'removeListItem', payload: { listId: fakeList.id, itemId: 'tmp' } }, + ], + expectedMutations: [ + { + type: types.SET_ERROR, + payload: 'An error occurred while creating the issue. Please try again.', + }, + ], + }); + }); }); }); describe('setActiveIssueLabels', () => { const state = { boardItems: { [mockIssue.id]: mockIssue } }; - const getters = { activeIssue: mockIssue }; + const getters = { activeBoardItem: mockIssue }; const testLabelIds = labels.map((label) => label.id); const input = { addLabelIds: testLabelIds, @@ -959,7 +1276,7 @@ describe('setActiveIssueLabels', () => { .mockResolvedValue({ data: { updateIssue: { issue: { labels: { nodes: labels } } } } }); const payload = { - issueId: getters.activeIssue.id, + itemId: getters.activeBoardItem.id, prop: 'labels', value: labels, }; @@ -970,7 +1287,7 @@ describe('setActiveIssueLabels', () => { { ...state, ...getters }, [ { - type: types.UPDATE_ISSUE_BY_ID, + type: types.UPDATE_BOARD_ITEM_BY_ID, payload, }, ], @@ -990,7 +1307,7 @@ describe('setActiveIssueLabels', () => { describe('setActiveIssueDueDate', () => { const state = { boardItems: { [mockIssue.id]: mockIssue } }; - const getters = { activeIssue: mockIssue }; + const getters = { activeBoardItem: mockIssue }; const testDueDate = '2020-02-20'; const input = { dueDate: testDueDate, @@ -1010,7 +1327,7 @@ describe('setActiveIssueDueDate', () => { }); const payload = { - issueId: getters.activeIssue.id, + itemId: getters.activeBoardItem.id, prop: 'dueDate', value: testDueDate, }; @@ -1021,7 +1338,7 @@ describe('setActiveIssueDueDate', () => { { ...state, ...getters }, [ { - type: types.UPDATE_ISSUE_BY_ID, + type: types.UPDATE_BOARD_ITEM_BY_ID, payload, }, ], @@ -1039,9 +1356,15 @@ describe('setActiveIssueDueDate', () => { }); }); -describe('setActiveIssueSubscribed', () => { - const state = { boardItems: { [mockActiveIssue.id]: mockActiveIssue } }; - const getters = { activeIssue: mockActiveIssue }; +describe('setActiveItemSubscribed', () => { + const state = { + boardItems: { + [mockActiveIssue.id]: mockActiveIssue, + }, + fullPath: 'gitlab-org', + issuableType: 'issue', + }; + const getters = { activeBoardItem: mockActiveIssue, isEpicBoard: false }; const subscribedState = true; const input = { subscribedState, @@ -1051,7 +1374,7 @@ describe('setActiveIssueSubscribed', () => { it('should commit subscribed status', (done) => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { - issueSetSubscription: { + updateIssuableSubscription: { issue: { subscribed: subscribedState, }, @@ -1061,18 +1384,18 @@ describe('setActiveIssueSubscribed', () => { }); const payload = { - issueId: getters.activeIssue.id, + itemId: getters.activeBoardItem.id, prop: 'subscribed', value: subscribedState, }; testAction( - actions.setActiveIssueSubscribed, + actions.setActiveItemSubscribed, input, { ...state, ...getters }, [ { - type: types.UPDATE_ISSUE_BY_ID, + type: types.UPDATE_BOARD_ITEM_BY_ID, payload, }, ], @@ -1084,15 +1407,15 @@ describe('setActiveIssueSubscribed', () => { it('throws error if fails', async () => { jest .spyOn(gqlClient, 'mutate') - .mockResolvedValue({ data: { issueSetSubscription: { errors: ['failed mutation'] } } }); + .mockResolvedValue({ data: { updateIssuableSubscription: { errors: ['failed mutation'] } } }); - await expect(actions.setActiveIssueSubscribed({ getters }, input)).rejects.toThrow(Error); + await expect(actions.setActiveItemSubscribed({ getters }, input)).rejects.toThrow(Error); }); }); describe('setActiveIssueMilestone', () => { const state = { boardItems: { [mockIssue.id]: mockIssue } }; - const getters = { activeIssue: mockIssue }; + const getters = { activeBoardItem: mockIssue }; const testMilestone = { ...mockMilestone, id: 'gid://gitlab/Milestone/1', @@ -1115,7 +1438,7 @@ describe('setActiveIssueMilestone', () => { }); const payload = { - issueId: getters.activeIssue.id, + itemId: getters.activeBoardItem.id, prop: 'milestone', value: testMilestone, }; @@ -1126,7 +1449,7 @@ describe('setActiveIssueMilestone', () => { { ...state, ...getters }, [ { - type: types.UPDATE_ISSUE_BY_ID, + type: types.UPDATE_BOARD_ITEM_BY_ID, payload, }, ], @@ -1144,9 +1467,13 @@ describe('setActiveIssueMilestone', () => { }); }); -describe('setActiveIssueTitle', () => { - const state = { boardItems: { [mockIssue.id]: mockIssue } }; - const getters = { activeIssue: mockIssue }; +describe('setActiveItemTitle', () => { + const state = { + boardItems: { [mockIssue.id]: mockIssue }, + issuableType: 'issue', + fullPath: 'path/f', + }; + const getters = { activeBoardItem: mockIssue, isEpicBoard: false }; const testTitle = 'Test Title'; const input = { title: testTitle, @@ -1156,7 +1483,7 @@ describe('setActiveIssueTitle', () => { it('should commit title after setting the issue', (done) => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { - updateIssue: { + updateIssuableTitle: { issue: { title: testTitle, }, @@ -1166,18 +1493,18 @@ describe('setActiveIssueTitle', () => { }); const payload = { - issueId: getters.activeIssue.id, + itemId: getters.activeBoardItem.id, prop: 'title', value: testTitle, }; testAction( - actions.setActiveIssueTitle, + actions.setActiveItemTitle, input, { ...state, ...getters }, [ { - type: types.UPDATE_ISSUE_BY_ID, + type: types.UPDATE_BOARD_ITEM_BY_ID, payload, }, ], @@ -1191,7 +1518,7 @@ describe('setActiveIssueTitle', () => { .spyOn(gqlClient, 'mutate') .mockResolvedValue({ data: { updateIssue: { errors: ['failed mutation'] } } }); - await expect(actions.setActiveIssueTitle({ getters }, input)).rejects.toThrow(Error); + await expect(actions.setActiveItemTitle({ getters }, input)).rejects.toThrow(Error); }); }); @@ -1321,7 +1648,7 @@ describe('toggleBoardItemMultiSelection', () => { testAction( actions.toggleBoardItemMultiSelection, boardItem2, - { activeId: mockActiveIssue.id, activeIssue: mockActiveIssue, selectedBoardItems: [] }, + { activeId: mockActiveIssue.id, activeBoardItem: mockActiveIssue, selectedBoardItems: [] }, [ { type: types.ADD_BOARD_ITEM_TO_SELECTION, @@ -1378,6 +1705,51 @@ describe('toggleBoardItem', () => { }); }); +describe('setError', () => { + it('should commit mutation SET_ERROR', () => { + testAction({ + action: actions.setError, + payload: { message: 'mayday' }, + expectedMutations: [ + { + payload: 'mayday', + type: types.SET_ERROR, + }, + ], + }); + }); + + it('should capture error using Sentry when captureError is true', () => { + jest.spyOn(Sentry, 'captureException'); + + const mockError = new Error(); + actions.setError( + { commit: () => {} }, + { + message: 'mayday', + error: mockError, + captureError: true, + }, + ); + + expect(Sentry.captureException).toHaveBeenNthCalledWith(1, mockError); + }); +}); + +describe('unsetError', () => { + it('should commit mutation SET_ERROR with undefined as payload', () => { + testAction({ + action: actions.unsetError, + expectedMutations: [ + { + payload: undefined, + type: types.SET_ERROR, + }, + ], + }); + }); +}); + describe('fetchBacklog', () => { expectNotImplemented(actions.fetchBacklog); }); diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js index 32d73d861bc..6114ba0af5f 100644 --- a/spec/frontend/boards/stores/getters_spec.js +++ b/spec/frontend/boards/stores/getters_spec.js @@ -88,7 +88,7 @@ describe('Boards - Getters', () => { }); }); - describe('activeIssue', () => { + describe('activeBoardItem', () => { it.each` id | expected ${'1'} | ${'issue'} @@ -96,7 +96,7 @@ describe('Boards - Getters', () => { `('returns $expected when $id is passed to state', ({ id, expected }) => { const state = { boardItems: { 1: 'issue' }, activeId: id }; - expect(getters.activeIssue(state)).toEqual(expected); + expect(getters.activeBoardItem(state)).toEqual(expected); }); }); @@ -105,14 +105,14 @@ describe('Boards - Getters', () => { const mockActiveIssue = { referencePath: 'gitlab-org/gitlab-test#1', }; - expect(getters.groupPathForActiveIssue({}, { activeIssue: mockActiveIssue })).toEqual( + expect(getters.groupPathForActiveIssue({}, { activeBoardItem: mockActiveIssue })).toEqual( 'gitlab-org', ); }); it('returns empty string as group path when active issue is an empty object', () => { const mockActiveIssue = {}; - expect(getters.groupPathForActiveIssue({}, { activeIssue: mockActiveIssue })).toEqual(''); + expect(getters.groupPathForActiveIssue({}, { activeBoardItem: mockActiveIssue })).toEqual(''); }); }); @@ -121,14 +121,16 @@ describe('Boards - Getters', () => { const mockActiveIssue = { referencePath: 'gitlab-org/gitlab-test#1', }; - expect(getters.projectPathForActiveIssue({}, { activeIssue: mockActiveIssue })).toEqual( + expect(getters.projectPathForActiveIssue({}, { activeBoardItem: mockActiveIssue })).toEqual( 'gitlab-org/gitlab-test', ); }); it('returns empty string as project path when active issue is an empty object', () => { const mockActiveIssue = {}; - expect(getters.projectPathForActiveIssue({}, { activeIssue: mockActiveIssue })).toEqual(''); + expect(getters.projectPathForActiveIssue({}, { activeBoardItem: mockActiveIssue })).toEqual( + '', + ); }); }); @@ -177,4 +179,31 @@ describe('Boards - Getters', () => { expect(getters.activeGroupProjects(state)).toEqual([mockGroupProject1]); }); }); + + describe('isIssueBoard', () => { + it.each` + issuableType | expected + ${'issue'} | ${true} + ${'epic'} | ${false} + `( + 'returns $expected when issuableType on state is $issuableType', + ({ issuableType, expected }) => { + const state = { + issuableType, + }; + + expect(getters.isIssueBoard(state)).toBe(expected); + }, + ); + }); + + describe('isEpicBoard', () => { + afterEach(() => { + window.gon = { features: {} }; + }); + + it('returns false', () => { + expect(getters.isEpicBoard()).toBe(false); + }); + }); }); diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index 33897cc0250..af6d439e294 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -1,3 +1,4 @@ +import { cloneDeep } from 'lodash'; import { issuableTypes } from '~/boards/constants'; import * as types from '~/boards/stores/mutation_types'; import mutations from '~/boards/stores/mutations'; @@ -9,6 +10,7 @@ import { mockIssue2, mockGroupProjects, labels, + mockList, } from '../mock_data'; const expectNotImplemented = (action) => { @@ -25,6 +27,14 @@ describe('Board Store Mutations', () => { 'gid://gitlab/List/2': mockLists[1], }; + const setBoardsListsState = () => { + state = cloneDeep({ + ...state, + boardItemsByListId: { 'gid://gitlab/List/1': [mockIssue.id] }, + boardLists: { 'gid://gitlab/List/1': mockList }, + }); + }; + beforeEach(() => { state = defaultState(); }); @@ -335,7 +345,7 @@ describe('Board Store Mutations', () => { expectNotImplemented(mutations.REQUEST_ADD_ISSUE); }); - describe('UPDATE_ISSUE_BY_ID', () => { + describe('UPDATE_BOARD_ITEM_BY_ID', () => { const issueId = '1'; const prop = 'id'; const value = '2'; @@ -353,8 +363,8 @@ describe('Board Store Mutations', () => { describe('when the issue is in state', () => { it('updates the property of the correct issue', () => { - mutations.UPDATE_ISSUE_BY_ID(state, { - issueId, + mutations.UPDATE_BOARD_ITEM_BY_ID(state, { + itemId: issueId, prop, value, }); @@ -366,8 +376,8 @@ describe('Board Store Mutations', () => { describe('when the issue is not in state', () => { it('throws an error', () => { expect(() => { - mutations.UPDATE_ISSUE_BY_ID(state, { - issueId: '3', + mutations.UPDATE_BOARD_ITEM_BY_ID(state, { + itemId: '3', prop, value, }); @@ -384,41 +394,7 @@ describe('Board Store Mutations', () => { expectNotImplemented(mutations.RECEIVE_ADD_ISSUE_ERROR); }); - describe('MOVE_ISSUE', () => { - it('updates boardItemsByListId, moving issue between lists', () => { - const listIssues = { - 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id], - 'gid://gitlab/List/2': [], - }; - - const issues = { - 1: mockIssue, - 2: mockIssue2, - }; - - state = { - ...state, - boardItemsByListId: listIssues, - boardLists: initialBoardListsState, - boardItems: issues, - }; - - mutations.MOVE_ISSUE(state, { - originalIssue: mockIssue2, - fromListId: 'gid://gitlab/List/1', - toListId: 'gid://gitlab/List/2', - }); - - const updatedListIssues = { - 'gid://gitlab/List/1': [mockIssue.id], - 'gid://gitlab/List/2': [mockIssue2.id], - }; - - expect(state.boardItemsByListId).toEqual(updatedListIssues); - }); - }); - - describe('MOVE_ISSUE_SUCCESS', () => { + describe('MUTATE_ISSUE_SUCCESS', () => { it('updates issue in issues state', () => { const issues = { 436: { id: rawIssue.id }, @@ -429,7 +405,7 @@ describe('Board Store Mutations', () => { boardItems: issues, }; - mutations.MOVE_ISSUE_SUCCESS(state, { + mutations.MUTATE_ISSUE_SUCCESS(state, { issue: rawIssue, }); @@ -437,33 +413,24 @@ describe('Board Store Mutations', () => { }); }); - describe('MOVE_ISSUE_FAILURE', () => { - it('updates boardItemsByListId, reverting moving issue between lists, and sets error message', () => { - const listIssues = { - 'gid://gitlab/List/1': [mockIssue.id], - 'gid://gitlab/List/2': [mockIssue2.id], - }; + describe('UPDATE_BOARD_ITEM', () => { + it('updates the given issue in state.boardItems', () => { + const updatedIssue = { id: 'some_gid', foo: 'bar' }; + state = { boardItems: { some_gid: { id: 'some_gid' } } }; - state = { - ...state, - boardItemsByListId: listIssues, - boardLists: initialBoardListsState, - }; + mutations.UPDATE_BOARD_ITEM(state, updatedIssue); - mutations.MOVE_ISSUE_FAILURE(state, { - originalIssue: mockIssue2, - fromListId: 'gid://gitlab/List/1', - toListId: 'gid://gitlab/List/2', - originalIndex: 1, - }); + expect(state.boardItems.some_gid).toEqual(updatedIssue); + }); + }); - const updatedListIssues = { - 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id], - 'gid://gitlab/List/2': [], - }; + describe('REMOVE_BOARD_ITEM', () => { + it('removes the given issue from state.boardItems', () => { + state = { boardItems: { some_gid: {}, some_gid2: {} } }; + + mutations.REMOVE_BOARD_ITEM(state, 'some_gid'); - expect(state.boardItemsByListId).toEqual(updatedListIssues); - expect(state.error).toEqual('An error occurred while moving the issue. Please try again.'); + expect(state.boardItems).toEqual({ some_gid2: {} }); }); }); @@ -479,85 +446,89 @@ describe('Board Store Mutations', () => { expectNotImplemented(mutations.RECEIVE_UPDATE_ISSUE_ERROR); }); - describe('CREATE_ISSUE_FAILURE', () => { - it('sets error message on state', () => { - mutations.CREATE_ISSUE_FAILURE(state); + describe('ADD_BOARD_ITEM_TO_LIST', () => { + beforeEach(() => { + setBoardsListsState(); + }); + + it.each([ + [ + 'at position 0 by default', + { + payload: { + itemId: mockIssue2.id, + listId: mockList.id, + }, + listState: [mockIssue2.id, mockIssue.id], + }, + ], + [ + 'at a given position', + { + payload: { + itemId: mockIssue2.id, + listId: mockList.id, + atIndex: 1, + }, + listState: [mockIssue.id, mockIssue2.id], + }, + ], + [ + "below the issue with id of 'moveBeforeId'", + { + payload: { + itemId: mockIssue2.id, + listId: mockList.id, + moveBeforeId: mockIssue.id, + }, + listState: [mockIssue.id, mockIssue2.id], + }, + ], + [ + "above the issue with id of 'moveAfterId'", + { + payload: { + itemId: mockIssue2.id, + listId: mockList.id, + moveAfterId: mockIssue.id, + }, + listState: [mockIssue2.id, mockIssue.id], + }, + ], + ])(`inserts an item into a list %s`, (_, { payload, listState }) => { + mutations.ADD_BOARD_ITEM_TO_LIST(state, payload); - expect(state.error).toBe('An error occurred while creating the issue. Please try again.'); + expect(state.boardItemsByListId[payload.listId]).toEqual(listState); }); - }); - - describe('ADD_ISSUE_TO_LIST', () => { - it('adds issue to issues state and issue id in list in boardItemsByListId', () => { - const listIssues = { - 'gid://gitlab/List/1': [mockIssue.id], - }; - const issues = { - 1: mockIssue, - }; - - state = { - ...state, - boardItemsByListId: listIssues, - boardItems: issues, - boardLists: initialBoardListsState, - }; + it("updates the list's items count", () => { expect(state.boardLists['gid://gitlab/List/1'].issuesCount).toBe(1); - mutations.ADD_ISSUE_TO_LIST(state, { list: mockLists[0], issue: mockIssue2 }); + mutations.ADD_BOARD_ITEM_TO_LIST(state, { + itemId: mockIssue2.id, + listId: mockList.id, + }); - expect(state.boardItemsByListId['gid://gitlab/List/1']).toContain(mockIssue2.id); - expect(state.boardItems[mockIssue2.id]).toEqual(mockIssue2); expect(state.boardLists['gid://gitlab/List/1'].issuesCount).toBe(2); }); }); - describe('ADD_ISSUE_TO_LIST_FAILURE', () => { - it('removes issue id from list in boardItemsByListId and sets error message', () => { - const listIssues = { - 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id], - }; - const issues = { - 1: mockIssue, - 2: mockIssue2, - }; - - state = { - ...state, - boardItemsByListId: listIssues, - boardItems: issues, - boardLists: initialBoardListsState, - }; - - mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issueId: mockIssue2.id }); - - expect(state.boardItemsByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id); - expect(state.error).toBe('An error occurred while creating the issue. Please try again.'); + describe('REMOVE_BOARD_ITEM_FROM_LIST', () => { + beforeEach(() => { + setBoardsListsState(); }); - }); - describe('REMOVE_ISSUE_FROM_LIST', () => { - it('removes issue id from list in boardItemsByListId and deletes issue from state', () => { - const listIssues = { - 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id], - }; - const issues = { - 1: mockIssue, - 2: mockIssue2, - }; - - state = { - ...state, - boardItemsByListId: listIssues, - boardItems: issues, - boardLists: initialBoardListsState, - }; + it("removes an item from a list and updates the list's items count", () => { + expect(state.boardLists['gid://gitlab/List/1'].issuesCount).toBe(1); + expect(state.boardItemsByListId['gid://gitlab/List/1']).toContain(mockIssue.id); - mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issueId: mockIssue2.id }); + mutations.REMOVE_BOARD_ITEM_FROM_LIST(state, { + itemId: mockIssue.id, + listId: mockList.id, + }); - expect(state.boardItemsByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id); - expect(state.boardItems).not.toContain(mockIssue2); + expect(state.boardItemsByListId['gid://gitlab/List/1']).not.toContain(mockIssue.id); + expect(state.boardLists['gid://gitlab/List/1'].issuesCount).toBe(0); }); }); @@ -666,4 +637,14 @@ describe('Board Store Mutations', () => { expect(state.selectedBoardItems).toEqual([]); }); }); + + describe('SET_ERROR', () => { + it('Should set error state', () => { + state.error = undefined; + + mutations[types.SET_ERROR](state, 'mayday'); + + expect(state.error).toBe('mayday'); + }); + }); }); diff --git a/spec/frontend/branches/components/sort_dropdown_spec.js b/spec/frontend/branches/components/sort_dropdown_spec.js new file mode 100644 index 00000000000..16ed02bfa88 --- /dev/null +++ b/spec/frontend/branches/components/sort_dropdown_spec.js @@ -0,0 +1,91 @@ +import { GlSearchBoxByClick } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import SortDropdown from '~/branches/components/sort_dropdown.vue'; +import * as urlUtils from '~/lib/utils/url_utility'; + +describe('Branches Sort Dropdown', () => { + let wrapper; + + const createWrapper = (props = {}) => { + return extendedWrapper( + mount(SortDropdown, { + provide: { + mode: 'overview', + projectBranchesFilteredPath: '/root/ci-cd-project-demo/-/branches?state=all', + sortOptions: { + name_asc: 'Name', + updated_asc: 'Oldest updated', + updated_desc: 'Last updated', + }, + ...props, + }, + }), + ); + }; + + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByClick); + const findBranchesDropdown = () => wrapper.findByTestId('branches-dropdown'); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('When in overview mode', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('should have a search box with a placeholder', () => { + const searchBox = findSearchBox(); + + expect(searchBox.exists()).toBe(true); + expect(searchBox.find('input').attributes('placeholder')).toBe('Filter by branch name'); + }); + + it('should not have a branches dropdown when in overview mode', () => { + const branchesDropdown = findBranchesDropdown(); + + expect(branchesDropdown.exists()).toBe(false); + }); + }); + + describe('when in All branches mode', () => { + beforeEach(() => { + wrapper = createWrapper({ mode: 'all' }); + }); + + it('should have a search box with a placeholder', () => { + const searchBox = findSearchBox(); + + expect(searchBox.exists()).toBe(true); + expect(searchBox.find('input').attributes('placeholder')).toBe('Filter by branch name'); + }); + + it('should have a branches dropdown when in all branches mode', () => { + const branchesDropdown = findBranchesDropdown(); + + expect(branchesDropdown.exists()).toBe(true); + }); + }); + + describe('when submitting a search term', () => { + beforeEach(() => { + urlUtils.visitUrl = jest.fn(); + + wrapper = createWrapper(); + }); + + it('should call visitUrl', () => { + const searchBox = findSearchBox(); + + searchBox.vm.$emit('submit'); + + expect(urlUtils.visitUrl).toHaveBeenCalledWith( + '/root/ci-cd-project-demo/-/branches?state=all', + ); + }); + }); +}); diff --git a/spec/frontend/captcha/apollo_captcha_link_spec.js b/spec/frontend/captcha/apollo_captcha_link_spec.js new file mode 100644 index 00000000000..e7ff4812ee7 --- /dev/null +++ b/spec/frontend/captcha/apollo_captcha_link_spec.js @@ -0,0 +1,165 @@ +import { ApolloLink, Observable } from 'apollo-link'; + +import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link'; +import UnsolvedCaptchaError from '~/captcha/unsolved_captcha_error'; +import { waitForCaptchaToBeSolved } from '~/captcha/wait_for_captcha_to_be_solved'; + +jest.mock('~/captcha/wait_for_captcha_to_be_solved'); + +describe('apolloCaptchaLink', () => { + const SPAM_LOG_ID = 'SPAM_LOG_ID'; + const CAPTCHA_SITE_KEY = 'CAPTCHA_SITE_KEY'; + const CAPTCHA_RESPONSE = 'CAPTCHA_RESPONSE'; + + const SUCCESS_RESPONSE = { + data: { + user: { + id: 3, + name: 'foo', + }, + }, + errors: [], + }; + + const NON_CAPTCHA_ERROR_RESPONSE = { + data: { + user: null, + }, + errors: [ + { + message: 'Something is severely wrong with your query.', + path: ['user'], + locations: [{ line: 2, column: 3 }], + extensions: { + message: 'Object not found', + type: 2, + }, + }, + ], + }; + + const SPAM_ERROR_RESPONSE = { + data: { + user: null, + }, + errors: [ + { + message: 'Your Query was detected to be spam.', + path: ['user'], + locations: [{ line: 2, column: 3 }], + extensions: { + spam: true, + }, + }, + ], + }; + + const CAPTCHA_ERROR_RESPONSE = { + data: { + user: null, + }, + errors: [ + { + message: 'This is an unrelated error, captcha should still work despite this.', + path: ['user'], + locations: [{ line: 2, column: 3 }], + }, + { + message: 'You need to solve a Captcha.', + path: ['user'], + locations: [{ line: 2, column: 3 }], + extensions: { + spam: true, + needs_captcha_response: true, + captcha_site_key: CAPTCHA_SITE_KEY, + spam_log_id: SPAM_LOG_ID, + }, + }, + ], + }; + + let link; + + let mockLinkImplementation; + let mockContext; + + const setupLink = (...responses) => { + mockLinkImplementation = jest.fn().mockImplementation(() => { + return Observable.of(responses.shift()); + }); + link = ApolloLink.from([apolloCaptchaLink, new ApolloLink(mockLinkImplementation)]); + }; + + function mockOperation() { + mockContext = jest.fn(); + return { operationName: 'operation', variables: {}, setContext: mockContext }; + } + + it('successful responses are passed through', (done) => { + setupLink(SUCCESS_RESPONSE); + link.request(mockOperation()).subscribe((result) => { + expect(result).toEqual(SUCCESS_RESPONSE); + expect(mockLinkImplementation).toHaveBeenCalledTimes(1); + expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); + done(); + }); + }); + + it('non-spam related errors are passed through', (done) => { + setupLink(NON_CAPTCHA_ERROR_RESPONSE); + link.request(mockOperation()).subscribe((result) => { + expect(result).toEqual(NON_CAPTCHA_ERROR_RESPONSE); + expect(mockLinkImplementation).toHaveBeenCalledTimes(1); + expect(mockContext).not.toHaveBeenCalled(); + expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); + done(); + }); + }); + + it('unresolvable spam errors are passed through', (done) => { + setupLink(SPAM_ERROR_RESPONSE); + link.request(mockOperation()).subscribe((result) => { + expect(result).toEqual(SPAM_ERROR_RESPONSE); + expect(mockLinkImplementation).toHaveBeenCalledTimes(1); + expect(mockContext).not.toHaveBeenCalled(); + expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); + done(); + }); + }); + + describe('resolvable spam errors', () => { + it('re-submits request with spam headers if the captcha modal was solved correctly', (done) => { + waitForCaptchaToBeSolved.mockResolvedValue(CAPTCHA_RESPONSE); + setupLink(CAPTCHA_ERROR_RESPONSE, SUCCESS_RESPONSE); + link.request(mockOperation()).subscribe((result) => { + expect(result).toEqual(SUCCESS_RESPONSE); + expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY); + expect(mockContext).toHaveBeenCalledWith({ + headers: { + 'X-GitLab-Captcha-Response': CAPTCHA_RESPONSE, + 'X-GitLab-Spam-Log-Id': SPAM_LOG_ID, + }, + }); + expect(mockLinkImplementation).toHaveBeenCalledTimes(2); + done(); + }); + }); + + it('throws error if the captcha modal was not solved correctly', (done) => { + const error = new UnsolvedCaptchaError(); + waitForCaptchaToBeSolved.mockRejectedValue(error); + + setupLink(CAPTCHA_ERROR_RESPONSE, SUCCESS_RESPONSE); + link.request(mockOperation()).subscribe({ + next: done.catch, + error: (result) => { + expect(result).toEqual(error); + expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY); + expect(mockContext).not.toHaveBeenCalled(); + expect(mockLinkImplementation).toHaveBeenCalledTimes(1); + done(); + }, + }); + }); + }); +}); diff --git a/spec/frontend/cascading_settings/components/lock_popovers_spec.js b/spec/frontend/cascading_settings/components/lock_popovers_spec.js new file mode 100644 index 00000000000..585e6ac505b --- /dev/null +++ b/spec/frontend/cascading_settings/components/lock_popovers_spec.js @@ -0,0 +1,152 @@ +import { GlPopover } from '@gitlab/ui'; +import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; +import LockPopovers from '~/namespaces/cascading_settings/components/lock_popovers.vue'; + +describe('LockPopovers', () => { + const mockNamespace = { + full_name: 'GitLab Org / GitLab', + path: '/gitlab-org/gitlab/-/edit', + }; + + const createPopoverMountEl = ({ + lockedByApplicationSetting = false, + lockedByAncestor = false, + }) => { + const popoverMountEl = document.createElement('div'); + popoverMountEl.classList.add('js-cascading-settings-lock-popover-target'); + + const popoverData = { + locked_by_application_setting: lockedByApplicationSetting, + locked_by_ancestor: lockedByAncestor, + }; + + if (lockedByApplicationSetting) { + popoverMountEl.setAttribute('data-popover-data', JSON.stringify(popoverData)); + } else if (lockedByAncestor) { + popoverMountEl.setAttribute( + 'data-popover-data', + JSON.stringify({ ...popoverData, ancestor_namespace: mockNamespace }), + ); + } + + document.body.appendChild(popoverMountEl); + + return popoverMountEl; + }; + + let wrapper; + const createWrapper = () => { + wrapper = mountExtended(LockPopovers); + }; + + const findPopover = () => extendedWrapper(wrapper.find(GlPopover)); + const findByTextInPopover = (text, options) => + findPopover().findByText((_, element) => element.textContent === text, options); + + const expectPopoverMessageExists = (message) => { + expect(findByTextInPopover(message).exists()).toBe(true); + }; + const expectCorrectPopoverTarget = (popoverMountEl, popover = findPopover()) => { + expect(popover.props('target')).toEqual(popoverMountEl); + }; + + afterEach(() => { + document.body.innerHTML = ''; + }); + + describe('when setting is locked by an application setting', () => { + let popoverMountEl; + + beforeEach(() => { + popoverMountEl = createPopoverMountEl({ lockedByApplicationSetting: true }); + createWrapper(); + }); + + it('displays correct popover message', () => { + expectPopoverMessageExists('This setting has been enforced by an instance admin.'); + }); + + it('sets `target` prop correctly', () => { + expectCorrectPopoverTarget(popoverMountEl); + }); + }); + + describe('when setting is locked by an ancestor namespace', () => { + let popoverMountEl; + + beforeEach(() => { + popoverMountEl = createPopoverMountEl({ lockedByAncestor: true }); + createWrapper(); + }); + + it('displays correct popover message', () => { + expectPopoverMessageExists( + `This setting has been enforced by an owner of ${mockNamespace.full_name}.`, + ); + }); + + it('displays link to ancestor namespace', () => { + expect( + findByTextInPopover(mockNamespace.full_name, { + selector: `a[href="${mockNamespace.path}"]`, + }).exists(), + ).toBe(true); + }); + + it('sets `target` prop correctly', () => { + expectCorrectPopoverTarget(popoverMountEl); + }); + }); + + describe('when setting is locked by an application setting and an ancestor namespace', () => { + let popoverMountEl; + + beforeEach(() => { + popoverMountEl = createPopoverMountEl({ + lockedByAncestor: true, + lockedByApplicationSetting: true, + }); + createWrapper(); + }); + + it('application setting takes precedence and correct message is shown', () => { + expectPopoverMessageExists('This setting has been enforced by an instance admin.'); + }); + + it('sets `target` prop correctly', () => { + expectCorrectPopoverTarget(popoverMountEl); + }); + }); + + describe('when setting is not locked', () => { + beforeEach(() => { + createPopoverMountEl({ + lockedByAncestor: false, + lockedByApplicationSetting: false, + }); + createWrapper(); + }); + + it('does not render popover', () => { + expect(findPopover().exists()).toBe(false); + }); + }); + + describe('when there are multiple mount elements', () => { + let popoverMountEl1; + let popoverMountEl2; + + beforeEach(() => { + popoverMountEl1 = createPopoverMountEl({ lockedByApplicationSetting: true }); + popoverMountEl2 = createPopoverMountEl({ lockedByAncestor: true }); + createWrapper(); + }); + + it('mounts multiple popovers', () => { + const popovers = wrapper.findAll(GlPopover).wrappers; + + expectCorrectPopoverTarget(popoverMountEl1, popovers[0]); + expectCorrectPopoverTarget(popoverMountEl2, popovers[1]); + }); + }); +}); diff --git a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js index 991dc8592e9..752783a306a 100644 --- a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js @@ -1,6 +1,7 @@ -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlFormInput } from '@gitlab/ui'; import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; import Vuex from 'vuex'; +import CiEnvironmentsDropdown from '~/ci_variable_list/components/ci_environments_dropdown.vue'; import CiVariableModal from '~/ci_variable_list/components/ci_variable_modal.vue'; import { AWS_ACCESS_KEY_ID } from '~/ci_variable_list/constants'; import createStore from '~/ci_variable_list/store'; @@ -15,7 +16,7 @@ describe('Ci variable modal', () => { let store; const createComponent = (method, options = {}) => { - store = createStore(); + store = createStore({ isGroup: options.isGroup }); wrapper = method(CiVariableModal, { attachTo: document.body, stubs: { @@ -27,6 +28,7 @@ describe('Ci variable modal', () => { }); }; + const findCiEnvironmentsDropdown = () => wrapper.find(CiEnvironmentsDropdown); const findModal = () => wrapper.find(ModalStub); const findAddorUpdateButton = () => findModal() @@ -149,6 +151,43 @@ describe('Ci variable modal', () => { }); }); + describe('Environment scope', () => { + describe('group level variables', () => { + it('renders the environment dropdown', () => { + createComponent(shallowMount, { + isGroup: true, + provide: { + glFeatures: { + groupScopedCiVariables: true, + }, + }, + }); + + expect(findCiEnvironmentsDropdown().exists()).toBe(true); + expect(findCiEnvironmentsDropdown().isVisible()).toBe(true); + }); + + describe('licensed feature is not available', () => { + it('disables the dropdown', () => { + createComponent(mount, { + isGroup: true, + provide: { + glFeatures: { + groupScopedCiVariables: false, + }, + }, + }); + + const environmentScopeInput = wrapper + .find('[data-testid="environment-scope"]') + .find(GlFormInput); + expect(findCiEnvironmentsDropdown().exists()).toBe(false); + expect(environmentScopeInput.attributes('readonly')).toBe('readonly'); + }); + }); + }); + }); + describe('Validations', () => { const maskError = 'This variable can not be masked.'; diff --git a/spec/frontend/ci_variable_list/components/ci_variable_table_spec.js b/spec/frontend/ci_variable_list/components/ci_variable_table_spec.js index ade2d65b857..8367c3f6bb8 100644 --- a/spec/frontend/ci_variable_list/components/ci_variable_table_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_variable_table_spec.js @@ -1,4 +1,3 @@ -import { GlTable } from '@gitlab/ui'; import { createLocalVue, mount } from '@vue/test-utils'; import Vuex from 'vuex'; import CiVariableTable from '~/ci_variable_list/components/ci_variable_table.vue'; @@ -14,7 +13,6 @@ describe('Ci variable table', () => { const createComponent = () => { store = createStore(); - store.state.isGroup = true; jest.spyOn(store, 'dispatch').mockImplementation(); wrapper = mount(CiVariableTable, { attachTo: document.body, @@ -26,7 +24,6 @@ describe('Ci variable table', () => { const findRevealButton = () => wrapper.find({ ref: 'secret-value-reveal-button' }); const findEditButton = () => wrapper.find({ ref: 'edit-ci-variable' }); const findEmptyVariablesPlaceholder = () => wrapper.find({ ref: 'empty-variables' }); - const findTable = () => wrapper.find(GlTable); beforeEach(() => { createComponent(); @@ -40,17 +37,6 @@ describe('Ci variable table', () => { expect(store.dispatch).toHaveBeenCalledWith('fetchVariables'); }); - it('fields prop does not contain environment_scope if group', () => { - expect(findTable().props('fields')).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - key: 'environment_scope', - label: 'Environment Scope', - }), - ]), - ); - }); - describe('Renders correct data', () => { it('displays empty message when variables are not present', () => { expect(findEmptyVariablesPlaceholder().exists()).toBe(true); diff --git a/spec/frontend/clusters/components/application_row_spec.js b/spec/frontend/clusters/components/application_row_spec.js index eff3493d7bd..6bad1db542b 100644 --- a/spec/frontend/clusters/components/application_row_spec.js +++ b/spec/frontend/clusters/components/application_row_spec.js @@ -89,6 +89,12 @@ describe('Application Row', () => { checkButtonState('Install', false, true); }); + it('has disabled "Externally installed" when APPLICATION_STATUS.EXTERNALLY_INSTALLED', () => { + mountComponent({ status: APPLICATION_STATUS.EXTERNALLY_INSTALLED }); + + checkButtonState('Externally installed', false, true); + }); + it('has disabled "Installed" when application is installed and not uninstallable', () => { mountComponent({ status: APPLICATION_STATUS.INSTALLED, diff --git a/spec/frontend/clusters/services/application_state_machine_spec.js b/spec/frontend/clusters/services/application_state_machine_spec.js index 55230625ba4..4e731e331c2 100644 --- a/spec/frontend/clusters/services/application_state_machine_spec.js +++ b/spec/frontend/clusters/services/application_state_machine_spec.js @@ -20,6 +20,8 @@ const { UNINSTALLING, UNINSTALL_ERRORED, UNINSTALLED, + PRE_INSTALLED, + EXTERNALLY_INSTALLED, } = APPLICATION_STATUS; const NO_EFFECTS = 'no effects'; @@ -29,19 +31,21 @@ describe('applicationStateMachine', () => { describe(`current state is ${NO_STATUS}`, () => { it.each` - expectedState | event | effects - ${INSTALLING} | ${SCHEDULED} | ${NO_EFFECTS} - ${NOT_INSTALLABLE} | ${NOT_INSTALLABLE} | ${NO_EFFECTS} - ${INSTALLABLE} | ${INSTALLABLE} | ${NO_EFFECTS} - ${INSTALLING} | ${INSTALLING} | ${NO_EFFECTS} - ${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS} - ${INSTALLABLE} | ${ERROR} | ${{ installFailed: true }} - ${UPDATING} | ${UPDATING} | ${NO_EFFECTS} - ${INSTALLED} | ${UPDATED} | ${NO_EFFECTS} - ${INSTALLED} | ${UPDATE_ERRORED} | ${{ updateFailed: true }} - ${UNINSTALLING} | ${UNINSTALLING} | ${NO_EFFECTS} - ${INSTALLED} | ${UNINSTALL_ERRORED} | ${{ uninstallFailed: true }} - ${UNINSTALLED} | ${UNINSTALLED} | ${NO_EFFECTS} + expectedState | event | effects + ${INSTALLING} | ${SCHEDULED} | ${NO_EFFECTS} + ${NOT_INSTALLABLE} | ${NOT_INSTALLABLE} | ${NO_EFFECTS} + ${INSTALLABLE} | ${INSTALLABLE} | ${NO_EFFECTS} + ${INSTALLING} | ${INSTALLING} | ${NO_EFFECTS} + ${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS} + ${INSTALLABLE} | ${ERROR} | ${{ installFailed: true }} + ${UPDATING} | ${UPDATING} | ${NO_EFFECTS} + ${INSTALLED} | ${UPDATED} | ${NO_EFFECTS} + ${INSTALLED} | ${UPDATE_ERRORED} | ${{ updateFailed: true }} + ${UNINSTALLING} | ${UNINSTALLING} | ${NO_EFFECTS} + ${INSTALLED} | ${UNINSTALL_ERRORED} | ${{ uninstallFailed: true }} + ${UNINSTALLED} | ${UNINSTALLED} | ${NO_EFFECTS} + ${PRE_INSTALLED} | ${PRE_INSTALLED} | ${NO_EFFECTS} + ${EXTERNALLY_INSTALLED} | ${EXTERNALLY_INSTALLED} | ${NO_EFFECTS} `(`transitions to $expectedState on $event event and applies $effects`, (data) => { const { expectedState, event, effects } = data; const currentAppState = { diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js new file mode 100644 index 00000000000..f055a49135b --- /dev/null +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -0,0 +1,26 @@ +import { shallowMount } from '@vue/test-utils'; +import { EditorContent } from 'tiptap'; +import ContentEditor from '~/content_editor/components/content_editor.vue'; +import createEditor from '~/content_editor/services/create_editor'; + +jest.mock('~/content_editor/services/create_editor'); + +describe('ContentEditor', () => { + let wrapper; + + const buildWrapper = () => { + wrapper = shallowMount(ContentEditor); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders editor content component and attaches editor instance', () => { + const editor = {}; + + createEditor.mockReturnValueOnce(editor); + buildWrapper(); + expect(wrapper.findComponent(EditorContent).props().editor).toBe(editor); + }); +}); diff --git a/spec/frontend/content_editor/markdown_processing_examples.js b/spec/frontend/content_editor/markdown_processing_examples.js new file mode 100644 index 00000000000..12bf2cbb747 --- /dev/null +++ b/spec/frontend/content_editor/markdown_processing_examples.js @@ -0,0 +1,19 @@ +import fs from 'fs'; +import path from 'path'; +import jsYaml from 'js-yaml'; +import { toArray } from 'lodash'; +import { getJSONFixture } from 'helpers/fixtures'; + +export const loadMarkdownApiResult = (testName) => { + const fixturePathPrefix = `api/markdown/${testName}.json`; + + return getJSONFixture(fixturePathPrefix); +}; + +export const loadMarkdownApiExamples = () => { + const apiMarkdownYamlPath = path.join(__dirname, '..', 'fixtures', 'api_markdown.yml'); + const apiMarkdownYamlText = fs.readFileSync(apiMarkdownYamlPath); + const apiMarkdownExampleObjects = jsYaml.safeLoad(apiMarkdownYamlText); + + return apiMarkdownExampleObjects.map((example) => toArray(example)); +}; diff --git a/spec/frontend/content_editor/markdown_processing_spec.js b/spec/frontend/content_editor/markdown_processing_spec.js new file mode 100644 index 00000000000..e435af30e9f --- /dev/null +++ b/spec/frontend/content_editor/markdown_processing_spec.js @@ -0,0 +1,12 @@ +import { createEditor } from '~/content_editor'; +import { loadMarkdownApiExamples, loadMarkdownApiResult } from './markdown_processing_examples'; + +describe('markdown processing', () => { + // Ensure we generate same markdown that was provided to Markdown API. + it.each(loadMarkdownApiExamples())('correctly handles %s', async (testName, markdown) => { + const { html } = loadMarkdownApiResult(testName); + const editor = await createEditor({ content: markdown, renderMarkdown: () => html }); + + expect(editor.getSerializedContent()).toBe(markdown); + }); +}); diff --git a/spec/frontend/content_editor/services/create_editor_spec.js b/spec/frontend/content_editor/services/create_editor_spec.js new file mode 100644 index 00000000000..4cf63e608eb --- /dev/null +++ b/spec/frontend/content_editor/services/create_editor_spec.js @@ -0,0 +1,39 @@ +import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '~/content_editor/constants'; +import createEditor from '~/content_editor/services/create_editor'; +import createMarkdownSerializer from '~/content_editor/services/markdown_serializer'; + +jest.mock('~/content_editor/services/markdown_serializer'); + +describe('content_editor/services/create_editor', () => { + const buildMockSerializer = () => ({ + serialize: jest.fn(), + deserialize: jest.fn(), + }); + + describe('creating an editor', () => { + it('uses markdown serializer when a renderMarkdown function is provided', async () => { + const renderMarkdown = () => true; + const mockSerializer = buildMockSerializer(); + createMarkdownSerializer.mockReturnValueOnce(mockSerializer); + + await createEditor({ renderMarkdown }); + + expect(createMarkdownSerializer).toHaveBeenCalledWith({ render: renderMarkdown }); + }); + + it('uses custom serializer when it is provided', async () => { + const mockSerializer = buildMockSerializer(); + const serializedContent = '**bold**'; + + mockSerializer.serialize.mockReturnValueOnce(serializedContent); + + const editor = await createEditor({ serializer: mockSerializer }); + + expect(editor.getSerializedContent()).toBe(serializedContent); + }); + + it('throws an error when neither a serializer or renderMarkdown fn are provided', async () => { + await expect(createEditor()).rejects.toThrow(PROVIDE_SERIALIZER_OR_RENDERER_ERROR); + }); + }); +}); diff --git a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap index a5eb42e0f08..15b052fffbb 100644 --- a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap +++ b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap @@ -5,7 +5,9 @@ exports[`Contributors charts should render charts when loading completed and the <div class="contributors-charts" > - <h4> + <h4 + class="gl-mb-2 gl-mt-5" + > Commits to master </h4> @@ -16,6 +18,7 @@ exports[`Contributors charts should render charts when loading completed and the <div> <glareachart-stub annotations="" + class="gl-mb-5" data="[object Object]" height="264" includelegendavgmax="true" @@ -34,14 +37,20 @@ exports[`Contributors charts should render charts when loading completed and the class="row" > <div - class="col-lg-6 col-12" + class="col-lg-6 col-12 gl-my-5" > - <h4> + <h4 + class="gl-mb-2 gl-mt-0" + > John </h4> - <p> + <p + class="gl-mb-3" + > + 2 commits (jawnnypoo@gmail.com) + </p> <div> diff --git a/spec/frontend/create_merge_request_dropdown_spec.js b/spec/frontend/create_merge_request_dropdown_spec.js index 08c05c6ec38..b4c13981dd5 100644 --- a/spec/frontend/create_merge_request_dropdown_spec.js +++ b/spec/frontend/create_merge_request_dropdown_spec.js @@ -20,7 +20,9 @@ describe('CreateMergeRequestDropdown', () => { </div> <div class="js-ref"></div> <div class="js-create-mr"></div> - <div class="js-create-merge-request"></div> + <div class="js-create-merge-request"> + <span class="js-spinner"></span> + </div> <div class="js-create-target"></div> <div class="js-dropdown-toggle"></div> </div> @@ -100,4 +102,18 @@ describe('CreateMergeRequestDropdown', () => { expect(dropdown.createMergeRequestButton.classList).toContain('disabled'); }); }); + + describe('setLoading', () => { + it.each` + loading | hasClass + ${true} | ${false} + ${false} | ${true} + `('it toggle loading spinner when loading is $loading', ({ loading, hasClass }) => { + dropdown.setLoading(loading); + + expect(document.querySelector('.js-spinner').classList.contains('gl-display-none')).toEqual( + hasClass, + ); + }); + }); }); diff --git a/spec/frontend/cycle_analytics/banner_spec.js b/spec/frontend/cycle_analytics/banner_spec.js index 0cae0298cee..ef7998c5ff5 100644 --- a/spec/frontend/cycle_analytics/banner_spec.js +++ b/spec/frontend/cycle_analytics/banner_spec.js @@ -1,45 +1,47 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import banner from '~/cycle_analytics/components/banner.vue'; +import { shallowMount } from '@vue/test-utils'; +import Banner from '~/cycle_analytics/components/banner.vue'; describe('Value Stream Analytics banner', () => { - let vm; + let wrapper; - beforeEach(() => { - const Component = Vue.extend(banner); - vm = mountComponent(Component, { - documentationLink: 'path', + const createComponent = () => { + wrapper = shallowMount(Banner, { + propsData: { + documentationLink: 'path', + }, }); + }; + + beforeEach(() => { + createComponent(); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('should render value stream analytics information', () => { - expect(vm.$el.querySelector('h4').textContent.trim()).toEqual( - 'Introducing Value Stream Analytics', - ); + expect(wrapper.find('h4').text().trim()).toBe('Introducing Value Stream Analytics'); expect( - vm.$el - .querySelector('p') - .textContent.trim() + 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(vm.$el.querySelector('a').textContent.trim()).toEqual('Read more'); - - expect(vm.$el.querySelector('a').getAttribute('href')).toEqual('path'); + 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', () => { - jest.spyOn(vm, '$emit').mockImplementation(() => {}); + it('should emit an event when close button is clicked', async () => { + jest.spyOn(wrapper.vm, '$emit').mockImplementation(() => {}); - vm.$el.querySelector('.js-ca-dismiss-button').click(); + await wrapper.find('.js-ca-dismiss-button').trigger('click'); - expect(vm.$emit).toHaveBeenCalled(); + expect(wrapper.vm.$emit).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/cycle_analytics/total_time_component_spec.js b/spec/frontend/cycle_analytics/total_time_component_spec.js index 0f7f2628aca..e831bc311ed 100644 --- a/spec/frontend/cycle_analytics/total_time_component_spec.js +++ b/spec/frontend/cycle_analytics/total_time_component_spec.js @@ -1,58 +1,58 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import component from '~/cycle_analytics/components/total_time_component.vue'; +import { shallowMount } from '@vue/test-utils'; +import TotalTime from '~/cycle_analytics/components/total_time_component.vue'; describe('Total time component', () => { - let vm; - let Component; + let wrapper; - beforeEach(() => { - Component = Vue.extend(component); - }); + const createComponent = (propsData) => { + wrapper = shallowMount(TotalTime, { + propsData, + }); + }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); describe('With data', () => { it('should render information for days and hours', () => { - vm = mountComponent(Component, { + createComponent({ time: { days: 3, hours: 4, }, }); - expect(vm.$el.textContent.trim().replace(/\s\s+/g, ' ')).toEqual('3 days 4 hrs'); + expect(wrapper.text()).toMatchInterpolatedText('3 days 4 hrs'); }); it('should render information for hours and minutes', () => { - vm = mountComponent(Component, { + createComponent({ time: { hours: 4, mins: 35, }, }); - expect(vm.$el.textContent.trim().replace(/\s\s+/g, ' ')).toEqual('4 hrs 35 mins'); + expect(wrapper.text()).toMatchInterpolatedText('4 hrs 35 mins'); }); it('should render information for seconds', () => { - vm = mountComponent(Component, { + createComponent({ time: { seconds: 45, }, }); - expect(vm.$el.textContent.trim().replace(/\s\s+/g, ' ')).toEqual('45 s'); + expect(wrapper.text()).toMatchInterpolatedText('45 s'); }); }); describe('Without data', () => { it('should render no information', () => { - vm = mountComponent(Component); + createComponent(); - expect(vm.$el.textContent.trim()).toEqual('--'); + expect(wrapper.text()).toBe('--'); }); }); }); diff --git a/spec/frontend/delete_label_modal_spec.js b/spec/frontend/delete_label_modal_spec.js new file mode 100644 index 00000000000..df70d3a8393 --- /dev/null +++ b/spec/frontend/delete_label_modal_spec.js @@ -0,0 +1,83 @@ +import { TEST_HOST } from 'helpers/test_constants'; +import initDeleteLabelModal from '~/delete_label_modal'; + +describe('DeleteLabelModal', () => { + const buttons = [ + { + labelName: 'label 1', + subjectName: 'GitLab Org', + destroyPath: `${TEST_HOST}/1`, + }, + { + labelName: 'label 2', + subjectName: 'GitLab Org', + destroyPath: `${TEST_HOST}/2`, + }, + ]; + + beforeEach(() => { + const buttonContainer = document.createElement('div'); + + buttons.forEach((x) => { + const button = document.createElement('button'); + button.setAttribute('class', 'js-delete-label-modal-button'); + button.setAttribute('data-label-name', x.labelName); + button.setAttribute('data-subject-name', x.subjectName); + button.setAttribute('data-destroy-path', x.destroyPath); + button.innerHTML = 'Action'; + buttonContainer.appendChild(button); + }); + + document.body.appendChild(buttonContainer); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + const findJsHooks = () => document.querySelectorAll('.js-delete-label-modal-button'); + const findModal = () => document.querySelector('.gl-modal'); + + it('starts with only js-containers', () => { + expect(findJsHooks()).toHaveLength(buttons.length); + expect(findModal()).not.toExist(); + }); + + describe('when first button clicked', () => { + beforeEach(() => { + initDeleteLabelModal(); + findJsHooks().item(0).click(); + }); + + it('does not replace js-containers with GlModal', () => { + expect(findJsHooks()).toHaveLength(buttons.length); + }); + + it('renders GlModal', () => { + expect(findModal()).toExist(); + }); + }); + + describe.each` + index + ${0} + ${1} + `(`when multiple buttons exist`, ({ index }) => { + beforeEach(() => { + initDeleteLabelModal(); + findJsHooks().item(index).click(); + }); + + it('correct props are passed to gl-modal', () => { + expect(findModal().querySelector('.modal-title').innerHTML).toContain( + buttons[index].labelName, + ); + expect(findModal().querySelector('.modal-body').innerHTML).toContain( + buttons[index].subjectName, + ); + expect(findModal().querySelector('.modal-footer .btn-danger').href).toContain( + buttons[index].destroyPath, + ); + }); + }); +}); diff --git a/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js b/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js index d8ce184940a..7c46c280d46 100644 --- a/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js +++ b/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js @@ -1,13 +1,16 @@ import { GlButton, GlModal } from '@gitlab/ui'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; +import Api from '~/api'; import DeployFreezeModal from '~/deploy_freeze/components/deploy_freeze_modal.vue'; import createStore from '~/deploy_freeze/store'; import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown.vue'; import { freezePeriodsFixture, timezoneDataFixture } from '../helpers'; -const localVue = createLocalVue(); -localVue.use(Vuex); +jest.mock('~/api'); + +Vue.use(Vuex); describe('Deploy freeze modal', () => { let wrapper; @@ -23,18 +26,19 @@ describe('Deploy freeze modal', () => { stubs: { GlModal, }, - localVue, store, }); }); - const findModal = () => wrapper.find(GlModal); - const addDeployFreezeButton = () => findModal().findAll(GlButton).at(1); + const findModal = () => wrapper.findComponent(GlModal); + const submitDeployFreezeButton = () => findModal().findAllComponents(GlButton).at(1); - const setInput = (freezeStartCron, freezeEndCron, selectedTimezone) => { + const setInput = (freezeStartCron, freezeEndCron, selectedTimezone, id = '') => { store.state.freezeStartCron = freezeStartCron; store.state.freezeEndCron = freezeEndCron; store.state.selectedTimezone = selectedTimezone; + store.state.selectedTimezoneIdentifier = selectedTimezone; + store.state.selectedId = id; wrapper.find('#deploy-freeze-start').trigger('input'); wrapper.find('#deploy-freeze-end').trigger('input'); @@ -48,18 +52,36 @@ describe('Deploy freeze modal', () => { describe('Basic interactions', () => { it('button is disabled when freeze period is invalid', () => { - expect(addDeployFreezeButton().attributes('disabled')).toBeTruthy(); + expect(submitDeployFreezeButton().attributes('disabled')).toBeTruthy(); }); }); describe('Adding a new deploy freeze', () => { + const { freeze_start, freeze_end, cron_timezone } = freezePeriodsFixture[0]; + beforeEach(() => { - const { freeze_start, freeze_end, cron_timezone } = freezePeriodsFixture[0]; setInput(freeze_start, freeze_end, cron_timezone); }); it('button is enabled when valid freeze period settings are present', () => { - expect(addDeployFreezeButton().attributes('disabled')).toBeUndefined(); + expect(submitDeployFreezeButton().attributes('disabled')).toBeUndefined(); + }); + + it('should display Add deploy freeze', () => { + expect(findModal().props('title')).toBe('Add deploy freeze'); + expect(submitDeployFreezeButton().text()).toBe('Add deploy freeze'); + }); + + it('should call the add deploy freze API', () => { + Api.createFreezePeriod.mockResolvedValue(); + findModal().vm.$emit('primary'); + + expect(Api.createFreezePeriod).toHaveBeenCalledTimes(1); + expect(Api.createFreezePeriod).toHaveBeenCalledWith(store.state.projectId, { + freeze_start, + freeze_end, + cron_timezone, + }); }); }); @@ -70,7 +92,7 @@ describe('Deploy freeze modal', () => { }); it('disables the add deploy freeze button', () => { - expect(addDeployFreezeButton().attributes('disabled')).toBeTruthy(); + expect(submitDeployFreezeButton().attributes('disabled')).toBeTruthy(); }); }); @@ -81,7 +103,32 @@ describe('Deploy freeze modal', () => { }); it('does not disable the submit button', () => { - expect(addDeployFreezeButton().attributes('disabled')).toBeFalsy(); + expect(submitDeployFreezeButton().attributes('disabled')).toBeFalsy(); + }); + }); + }); + + describe('Editing an existing deploy freeze', () => { + const { freeze_start, freeze_end, cron_timezone, id } = freezePeriodsFixture[0]; + beforeEach(() => { + setInput(freeze_start, freeze_end, cron_timezone, id); + }); + + it('should display Edit deploy freeze', () => { + expect(findModal().props('title')).toBe('Edit deploy freeze'); + expect(submitDeployFreezeButton().text()).toBe('Save deploy freeze'); + }); + + it('should call the update deploy freze API', () => { + Api.updateFreezePeriod.mockResolvedValue(); + findModal().vm.$emit('primary'); + + expect(Api.updateFreezePeriod).toHaveBeenCalledTimes(1); + expect(Api.updateFreezePeriod).toHaveBeenCalledWith(store.state.projectId, { + id, + freeze_start, + freeze_end, + cron_timezone, }); }); }); 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 e4ee1b9ad26..168ddcfeacc 100644 --- a/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js +++ b/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js @@ -2,6 +2,7 @@ import { createLocalVue, mount } from '@vue/test-utils'; import Vuex from 'vuex'; import DeployFreezeTable from '~/deploy_freeze/components/deploy_freeze_table.vue'; import createStore from '~/deploy_freeze/store'; +import { RECEIVE_FREEZE_PERIODS_SUCCESS } from '~/deploy_freeze/store/mutation_types'; import { freezePeriodsFixture, timezoneDataFixture } from '../helpers'; const localVue = createLocalVue(); @@ -26,6 +27,7 @@ describe('Deploy freeze table', () => { const findEmptyFreezePeriods = () => wrapper.find('[data-testid="empty-freeze-periods"]'); 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"]'); beforeEach(() => { @@ -45,17 +47,31 @@ describe('Deploy freeze table', () => { it('displays empty', () => { expect(findEmptyFreezePeriods().exists()).toBe(true); expect(findEmptyFreezePeriods().text()).toBe( - 'No deploy freezes exist for this project. To add one, click Add deploy freeze', + 'No deploy freezes exist for this project. To add one, select Add deploy freeze', ); }); - it('displays data', () => { - store.state.freezePeriods = freezePeriodsFixture; + describe('with data', () => { + beforeEach(async () => { + store.commit(RECEIVE_FREEZE_PERIODS_SUCCESS, freezePeriodsFixture); + await wrapper.vm.$nextTick(); + }); - return wrapper.vm.$nextTick(() => { + it('displays data', () => { const tableRows = findDeployFreezeTable().findAll('tbody tr'); expect(tableRows.length).toBe(freezePeriodsFixture.length); expect(findEmptyFreezePeriods().exists()).toBe(false); + expect(findEditDeployFreezeButton().exists()).toBe(true); + }); + + it('allows user to edit deploy freeze', async () => { + findEditDeployFreezeButton().trigger('click'); + await wrapper.vm.$nextTick(); + + expect(store.dispatch).toHaveBeenCalledWith( + 'setFreezePeriod', + store.state.freezePeriods[0], + ); }); }); }); diff --git a/spec/frontend/deploy_freeze/store/actions_spec.js b/spec/frontend/deploy_freeze/store/actions_spec.js index f4d9802e39a..9c784f3c5a2 100644 --- a/spec/frontend/deploy_freeze/store/actions_spec.js +++ b/spec/frontend/deploy_freeze/store/actions_spec.js @@ -23,12 +23,46 @@ describe('deploy freeze store actions', () => { }); Api.freezePeriods.mockResolvedValue({ data: freezePeriodsFixture }); Api.createFreezePeriod.mockResolvedValue(); + Api.updateFreezePeriod.mockResolvedValue(); }); afterEach(() => { mock.restore(); }); + describe('setSelectedFreezePeriod', () => { + it('commits SET_SELECTED_TIMEZONE mutation', () => { + testAction( + actions.setFreezePeriod, + { + id: 3, + cronTimezone: 'UTC', + freezeStart: 'start', + freezeEnd: 'end', + }, + {}, + [ + { + payload: 3, + type: types.SET_SELECTED_ID, + }, + { + payload: 'UTC', + type: types.SET_SELECTED_TIMEZONE, + }, + { + payload: 'start', + type: types.SET_FREEZE_START_CRON, + }, + { + payload: 'end', + type: types.SET_FREEZE_END_CRON, + }, + ], + ); + }); + }); + describe('setSelectedTimezone', () => { it('commits SET_SELECTED_TIMEZONE mutation', () => { testAction(actions.setSelectedTimezone, {}, {}, [ @@ -68,10 +102,16 @@ describe('deploy freeze store actions', () => { state, [{ type: 'RESET_MODAL' }], [ - { type: 'requestAddFreezePeriod' }, - { type: 'receiveAddFreezePeriodSuccess' }, + { type: 'requestFreezePeriod' }, + { type: 'receiveFreezePeriodSuccess' }, { type: 'fetchFreezePeriods' }, ], + () => + expect(Api.createFreezePeriod).toHaveBeenCalledWith(state.projectId, { + freeze_start: state.freezeStartCron, + freeze_end: state.freezeEndCron, + cron_timezone: state.selectedTimezoneIdentifier, + }), ); }); @@ -83,7 +123,43 @@ describe('deploy freeze store actions', () => { {}, state, [], - [{ type: 'requestAddFreezePeriod' }, { type: 'receiveAddFreezePeriodError' }], + [{ type: 'requestFreezePeriod' }, { type: 'receiveFreezePeriodError' }], + () => expect(createFlash).toHaveBeenCalled(), + ); + }); + }); + + describe('updateFreezePeriod', () => { + it('dispatch correct actions on updating a freeze period', () => { + testAction( + actions.updateFreezePeriod, + {}, + state, + [{ type: 'RESET_MODAL' }], + [ + { type: 'requestFreezePeriod' }, + { type: 'receiveFreezePeriodSuccess' }, + { type: 'fetchFreezePeriods' }, + ], + () => + expect(Api.updateFreezePeriod).toHaveBeenCalledWith(state.projectId, { + id: state.selectedId, + freeze_start: state.freezeStartCron, + freeze_end: state.freezeEndCron, + cron_timezone: state.selectedTimezoneIdentifier, + }), + ); + }); + + it('should show flash error and set error in state on add failure', () => { + Api.updateFreezePeriod.mockRejectedValue(); + + testAction( + actions.updateFreezePeriod, + {}, + state, + [], + [{ type: 'requestFreezePeriod' }, { type: 'receiveFreezePeriodError' }], () => expect(createFlash).toHaveBeenCalled(), ); }); diff --git a/spec/frontend/deploy_freeze/store/mutations_spec.js b/spec/frontend/deploy_freeze/store/mutations_spec.js index 54cbdfcb64c..ce75e3b89c3 100644 --- a/spec/frontend/deploy_freeze/store/mutations_spec.js +++ b/spec/frontend/deploy_freeze/store/mutations_spec.js @@ -33,7 +33,10 @@ describe('Deploy freeze mutations', () => { const expectedFreezePeriods = freezePeriodsFixture.map((freezePeriod, index) => ({ ...convertObjectPropsToCamelCase(freezePeriod), - cronTimezone: timezoneNames[index], + cronTimezone: { + formattedTimezone: timezoneNames[index], + identifier: freezePeriod.cronTimezone, + }, })); expect(stateCopy.freezePeriods).toMatchObject(expectedFreezePeriods); @@ -62,11 +65,19 @@ describe('Deploy freeze mutations', () => { }); }); - describe('SET_FREEZE_ENDT_CRON', () => { + describe('SET_FREEZE_END_CRON', () => { it('should set freezeEndCron', () => { mutations[types.SET_FREEZE_END_CRON](stateCopy, '5 0 * 8 *'); expect(stateCopy.freezeEndCron).toBe('5 0 * 8 *'); }); }); + + describe('SET_SELECTED_ID', () => { + it('should set selectedId', () => { + mutations[types.SET_SELECTED_ID](stateCopy, 5); + + expect(stateCopy.selectedId).toBe(5); + }); + }); }); diff --git a/spec/frontend/deploy_tokens/components/revoke_button_spec.js b/spec/frontend/deploy_tokens/components/revoke_button_spec.js new file mode 100644 index 00000000000..e70dfe4d2e6 --- /dev/null +++ b/spec/frontend/deploy_tokens/components/revoke_button_spec.js @@ -0,0 +1,108 @@ +import { GlModal } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import RevokeButton from '~/deploy_tokens/components/revoke_button.vue'; + +const mockToken = { + created_at: '2021-03-18T19:13:03.011Z', + deploy_token_type: 'project_type', + expires_at: null, + id: 1, + name: 'testtoken', + read_package_registry: true, + read_registry: false, + read_repository: true, + revoked: false, + token: 'xUVsGDfK4y_Xj5UhqvaH', + token_encrypted: 'JYeg+WK4obIlrhyAYWvBvaY7CNB/U3FPX3cdLrivAly5qToy', + username: 'gitlab+deploy-token-1', + write_package_registry: true, + write_registry: false, +}; +const mockRevokePath = ''; + +describe('RevokeButton', () => { + let wrapper; + let glModalDirective; + + function createComponent(injectedProperties = {}) { + glModalDirective = jest.fn(); + return extendedWrapper( + mount(RevokeButton, { + provide: { + token: mockToken, + revokePath: mockRevokePath, + ...injectedProperties, + }, + directives: { + glModal: { + bind(_, { value }) { + glModalDirective(value); + }, + }, + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: + '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>', + }), + }, + }), + ); + } + + afterEach(() => { + wrapper.destroy(); + }); + + const findRevokeButton = () => wrapper.findByTestId('revoke-button'); + const findModal = () => wrapper.findComponent(GlModal); + const findPrimaryModalButton = () => wrapper.findByTestId('primary-revoke-btn'); + + describe('template', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + describe('revoke button', () => { + it('displays the revoke button', () => { + expect(findRevokeButton().exists()).toBe(true); + }); + + it('passes the buttonClass to the button', () => { + wrapper = createComponent({ buttonClass: 'my-revoke-button' }); + expect(findRevokeButton().classes()).toContain('my-revoke-button'); + }); + + it('opens the modal', () => { + findRevokeButton().trigger('click'); + expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.modalId); + }); + }); + + describe('modal', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('renders the revoke modal', () => { + expect(findModal().exists()).toBe(true); + }); + + it('displays the token name in the modal title', () => { + expect(findModal().text()).toContain('Revoke testtoken'); + }); + + it('displays the token name in the primary action button"', () => { + expect(findPrimaryModalButton().text()).toBe('Revoke testtoken'); + }); + + it('passes the revokePath to the button', () => { + const revokePath = 'gitlab-org/gitlab-test/-/deploy-tokens/1/revoke'; + wrapper = createComponent({ revokePath }); + expect(findPrimaryModalButton().attributes('href')).toBe(revokePath); + }); + }); + }); +}); diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap index 5eb86d4f9cb..3cb48d7632f 100644 --- a/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap +++ b/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap @@ -13,6 +13,7 @@ exports[`Design management pagination component renders navigation buttons 1`] = class="gl-mx-5" > <gl-button-stub + aria-label="Go to previous design" buttontextclasses="" category="primary" class="js-previous-design" @@ -24,6 +25,7 @@ exports[`Design management pagination component renders navigation buttons 1`] = /> <gl-button-stub + aria-label="Go to next design" buttontextclasses="" category="primary" class="js-next-design" diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap index e2ad4c68bea..6dfd57906d8 100644 --- a/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap @@ -41,6 +41,7 @@ exports[`Design management toolbar component renders design and updated data 1`] /> <gl-button-stub + aria-label="Download design" buttontextclasses="" category="primary" href="/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d" diff --git a/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap index 904bb2022ca..191bcc2d484 100644 --- a/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap +++ b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap @@ -6,11 +6,11 @@ exports[`Design management upload button component renders inverted upload desig > <gl-button-stub buttontextclasses="" - category="primary" + category="secondary" icon="" size="small" title="Adding a design with the same filename replaces the file in a new version." - variant="default" + variant="confirm" > Upload designs @@ -31,11 +31,11 @@ exports[`Design management upload button component renders upload design button <div> <gl-button-stub buttontextclasses="" - category="primary" + category="secondary" icon="" size="small" title="Adding a design with the same filename replaces the file in a new version." - variant="default" + variant="confirm" > Upload designs diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index 34547238c23..8a1c5547581 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -56,6 +56,7 @@ describe('diffs/components/app', () => { endpointMetadata: `${TEST_HOST}/diff/endpointMetadata`, endpointBatch: `${TEST_HOST}/diff/endpointBatch`, endpointCoverage: `${TEST_HOST}/diff/endpointCoverage`, + endpointCodequality: '', projectPath: 'namespace/project', currentUser: {}, changesEmptyStateIllustration: '', @@ -105,7 +106,6 @@ describe('diffs/components/app', () => { jest.spyOn(wrapper.vm, 'fetchDiffFilesBatch').mockImplementation(fetchResolver); jest.spyOn(wrapper.vm, 'fetchCoverageFiles').mockImplementation(fetchResolver); jest.spyOn(wrapper.vm, 'setDiscussions').mockImplementation(() => {}); - jest.spyOn(wrapper.vm, 'startRenderDiffsQueue').mockImplementation(() => {}); jest.spyOn(wrapper.vm, 'unwatchDiscussions').mockImplementation(() => {}); jest.spyOn(wrapper.vm, 'unwatchRetrievingBatches').mockImplementation(() => {}); store.state.diffs.retrievingBatches = true; @@ -119,7 +119,6 @@ describe('diffs/components/app', () => { await nextTick(); - expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesMeta).toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesBatch).toHaveBeenCalled(); expect(wrapper.vm.fetchCoverageFiles).toHaveBeenCalled(); @@ -134,7 +133,6 @@ describe('diffs/components/app', () => { await nextTick(); - expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesMeta).toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesBatch).toHaveBeenCalled(); expect(wrapper.vm.fetchCoverageFiles).toHaveBeenCalled(); @@ -144,6 +142,16 @@ describe('diffs/components/app', () => { }); }); + describe('codequality diff', () => { + it('does not fetch code quality data on FOSS', async () => { + createComponent(); + jest.spyOn(wrapper.vm, 'fetchCodequality'); + wrapper.vm.fetchData(false); + + expect(wrapper.vm.fetchCodequality).not.toHaveBeenCalled(); + }); + }); + it.each` props | state | expected ${{ isFluidLayout: true }} | ${{ isParallelView: false }} | ${false} @@ -697,4 +705,24 @@ 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/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js index 8cb4fd20063..0191822d97a 100644 --- a/spec/frontend/diffs/components/commit_item_spec.js +++ b/spec/frontend/diffs/components/commit_item_spec.js @@ -13,8 +13,6 @@ const TEST_AUTHOR_EMAIL = 'test+test@gitlab.com'; const TEST_AUTHOR_GRAVATAR = `${TEST_HOST}/avatar/test?s=40`; const TEST_SIGNATURE_HTML = '<a>Legit commit</a>'; const TEST_PIPELINE_STATUS_PATH = `${TEST_HOST}/pipeline/status`; -const NEXT_COMMIT_URL = `${TEST_HOST}/?commit_id=next`; -const PREV_COMMIT_URL = `${TEST_HOST}/?commit_id=prev`; describe('diffs/components/commit_item', () => { let wrapper; @@ -31,12 +29,6 @@ describe('diffs/components/commit_item', () => { const getCommitActionsElement = () => wrapper.find('.commit-actions'); const getCommitPipelineStatus = () => wrapper.find(CommitPipelineStatus); - const getCommitNavButtonsElement = () => wrapper.find('.commit-nav-buttons'); - const getNextCommitNavElement = () => - getCommitNavButtonsElement().find('.btn-group > *:last-child'); - const getPrevCommitNavElement = () => - getCommitNavButtonsElement().find('.btn-group > *:first-child'); - const mountComponent = (propsData) => { wrapper = mount(Component, { propsData: { @@ -180,126 +172,4 @@ describe('diffs/components/commit_item', () => { expect(getCommitPipelineStatus().exists()).toBe(true); }); }); - - describe('without neighbor commits', () => { - beforeEach(() => { - mountComponent({ commit: { ...commit, prev_commit_id: null, next_commit_id: null } }); - }); - - it('does not render any navigation buttons', () => { - expect(getCommitNavButtonsElement().exists()).toEqual(false); - }); - }); - - describe('with neighbor commits', () => { - let mrCommit; - - beforeEach(() => { - mrCommit = { - ...commit, - next_commit_id: 'next', - prev_commit_id: 'prev', - }; - - mountComponent({ commit: mrCommit }); - }); - - it('renders the commit navigation buttons', () => { - expect(getCommitNavButtonsElement().exists()).toEqual(true); - - mountComponent({ - commit: { ...mrCommit, next_commit_id: null }, - }); - expect(getCommitNavButtonsElement().exists()).toEqual(true); - - mountComponent({ - commit: { ...mrCommit, prev_commit_id: null }, - }); - expect(getCommitNavButtonsElement().exists()).toEqual(true); - }); - - describe('prev commit', () => { - const { location } = window; - - beforeAll(() => { - delete window.location; - window.location = { href: `${TEST_HOST}?commit_id=${mrCommit.id}` }; - }); - - beforeEach(() => { - jest.spyOn(wrapper.vm, 'moveToNeighboringCommit').mockImplementation(() => {}); - }); - - afterAll(() => { - window.location = location; - }); - - it('uses the correct href', () => { - const link = getPrevCommitNavElement(); - - expect(link.element.getAttribute('href')).toEqual(PREV_COMMIT_URL); - }); - - it('triggers the correct Vuex action on click', () => { - const link = getPrevCommitNavElement(); - - link.trigger('click'); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.moveToNeighboringCommit).toHaveBeenCalledWith({ - direction: 'previous', - }); - }); - }); - - it('renders a disabled button when there is no prev commit', () => { - mountComponent({ commit: { ...mrCommit, prev_commit_id: null } }); - - const button = getPrevCommitNavElement(); - - expect(button.element.tagName).toEqual('BUTTON'); - expect(button.element.hasAttribute('disabled')).toEqual(true); - }); - }); - - describe('next commit', () => { - const { location } = window; - - beforeAll(() => { - delete window.location; - window.location = { href: `${TEST_HOST}?commit_id=${mrCommit.id}` }; - }); - - beforeEach(() => { - jest.spyOn(wrapper.vm, 'moveToNeighboringCommit').mockImplementation(() => {}); - }); - - afterAll(() => { - window.location = location; - }); - - it('uses the correct href', () => { - const link = getNextCommitNavElement(); - - expect(link.element.getAttribute('href')).toEqual(NEXT_COMMIT_URL); - }); - - it('triggers the correct Vuex action on click', () => { - const link = getNextCommitNavElement(); - - link.trigger('click'); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.moveToNeighboringCommit).toHaveBeenCalledWith({ direction: 'next' }); - }); - }); - - it('renders a disabled button when there is no next commit', () => { - mountComponent({ commit: { ...mrCommit, next_commit_id: null } }); - - const button = getNextCommitNavElement(); - - expect(button.element.tagName).toEqual('BUTTON'); - expect(button.element.hasAttribute('disabled')).toEqual(true); - }); - }); - }); }); diff --git a/spec/frontend/diffs/components/compare_versions_spec.js b/spec/frontend/diffs/components/compare_versions_spec.js index c93a3771ec0..a01ec1db35c 100644 --- a/spec/frontend/diffs/components/compare_versions_spec.js +++ b/spec/frontend/diffs/components/compare_versions_spec.js @@ -1,5 +1,6 @@ import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; +import { TEST_HOST } from 'helpers/test_constants'; import { trimText } from 'helpers/text_helper'; import CompareVersionsComponent from '~/diffs/components/compare_versions.vue'; import { createStore } from '~/mr_notes/stores'; @@ -9,12 +10,17 @@ import diffsMockData from '../mock_data/merge_request_diffs'; const localVue = createLocalVue(); localVue.use(Vuex); +const NEXT_COMMIT_URL = `${TEST_HOST}/?commit_id=next`; +const PREV_COMMIT_URL = `${TEST_HOST}/?commit_id=prev`; + describe('CompareVersions', () => { let wrapper; let store; const targetBranchName = 'tmp-wine-dev'; + const { commit } = getDiffWithCommit(); - const createWrapper = (props) => { + const createWrapper = (props = {}, commitArgs = {}) => { + store.state.diffs.commit = { ...store.state.diffs.commit, ...commitArgs }; wrapper = mount(CompareVersionsComponent, { localVue, store, @@ -28,6 +34,11 @@ describe('CompareVersions', () => { const findLimitedContainer = () => wrapper.find('.container-limited.limit-container-width'); const findCompareSourceDropdown = () => wrapper.find('.mr-version-dropdown'); const findCompareTargetDropdown = () => wrapper.find('.mr-version-compare-dropdown'); + const getCommitNavButtonsElement = () => wrapper.find('.commit-nav-buttons'); + const getNextCommitNavElement = () => + getCommitNavButtonsElement().find('.btn-group > *:last-child'); + const getPrevCommitNavElement = () => + getCommitNavButtonsElement().find('.btn-group > *:first-child'); beforeEach(() => { store = createStore(); @@ -161,4 +172,126 @@ describe('CompareVersions', () => { expect(findCompareTargetDropdown().exists()).toBe(false); }); }); + + describe('without neighbor commits', () => { + beforeEach(() => { + createWrapper({ commit: { ...commit, prev_commit_id: null, next_commit_id: null } }); + }); + + it('does not render any navigation buttons', () => { + expect(getCommitNavButtonsElement().exists()).toEqual(false); + }); + }); + + describe('with neighbor commits', () => { + let mrCommit; + + beforeEach(() => { + mrCommit = { + ...commit, + next_commit_id: 'next', + prev_commit_id: 'prev', + }; + + createWrapper({}, mrCommit); + }); + + it('renders the commit navigation buttons', () => { + expect(getCommitNavButtonsElement().exists()).toEqual(true); + + createWrapper({ + commit: { ...mrCommit, next_commit_id: null }, + }); + expect(getCommitNavButtonsElement().exists()).toEqual(true); + + createWrapper({ + commit: { ...mrCommit, prev_commit_id: null }, + }); + expect(getCommitNavButtonsElement().exists()).toEqual(true); + }); + + describe('prev commit', () => { + beforeAll(() => { + global.jsdom.reconfigure({ + url: `${TEST_HOST}?commit_id=${mrCommit.id}`, + }); + }); + + afterAll(() => { + global.jsdom.reconfigure({ + url: TEST_HOST, + }); + }); + + beforeEach(() => { + jest.spyOn(wrapper.vm, 'moveToNeighboringCommit').mockImplementation(() => {}); + }); + + it('uses the correct href', () => { + const link = getPrevCommitNavElement(); + + expect(link.element.getAttribute('href')).toEqual(PREV_COMMIT_URL); + }); + + it('triggers the correct Vuex action on click', () => { + const link = getPrevCommitNavElement(); + + link.trigger('click'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.moveToNeighboringCommit).toHaveBeenCalledWith({ + direction: 'previous', + }); + }); + }); + + it('renders a disabled button when there is no prev commit', () => { + createWrapper({}, { ...mrCommit, prev_commit_id: null }); + + const button = getPrevCommitNavElement(); + + expect(button.element.hasAttribute('disabled')).toEqual(true); + }); + }); + + describe('next commit', () => { + beforeAll(() => { + global.jsdom.reconfigure({ + url: `${TEST_HOST}?commit_id=${mrCommit.id}`, + }); + }); + + afterAll(() => { + global.jsdom.reconfigure({ + url: TEST_HOST, + }); + }); + + beforeEach(() => { + jest.spyOn(wrapper.vm, 'moveToNeighboringCommit').mockImplementation(() => {}); + }); + + it('uses the correct href', () => { + const link = getNextCommitNavElement(); + + expect(link.element.getAttribute('href')).toEqual(NEXT_COMMIT_URL); + }); + + it('triggers the correct Vuex action on click', () => { + const link = getNextCommitNavElement(); + + link.trigger('click'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.moveToNeighboringCommit).toHaveBeenCalledWith({ direction: 'next' }); + }); + }); + + it('renders a disabled button when there is no next commit', () => { + createWrapper({}, { ...mrCommit, next_commit_id: null }); + + const button = getNextCommitNavElement(); + + expect(button.element.hasAttribute('disabled')).toEqual(true); + }); + }); + }); }); diff --git a/spec/frontend/diffs/components/diff_row_spec.js b/spec/frontend/diffs/components/diff_row_spec.js index 5682b29d697..0bc1bd40f06 100644 --- a/spec/frontend/diffs/components/diff_row_spec.js +++ b/spec/frontend/diffs/components/diff_row_spec.js @@ -4,6 +4,7 @@ import Vuex from 'vuex'; import DiffRow from '~/diffs/components/diff_row.vue'; import { mapParallel } from '~/diffs/components/diff_row_utils'; import diffsModule from '~/diffs/store/modules'; +import { findInteropAttributes } from '../find_interop_attributes'; import diffFileMockData from '../mock_data/diff_file'; describe('DiffRow', () => { @@ -211,4 +212,20 @@ describe('DiffRow', () => { expect(coverage.classes('no-coverage')).toBeFalsy(); }); }); + + describe('interoperability', () => { + it.each` + desc | line | inline | leftSide | rightSide + ${'with inline and new_line'} | ${{ left: { old_line: 3, new_line: 5, type: 'new' } }} | ${true} | ${{ type: 'new', line: '5', oldLine: '3', newLine: '5' }} | ${null} + ${'with inline and no new_line'} | ${{ left: { old_line: 3, type: 'old' } }} | ${true} | ${{ type: 'old', line: '3', oldLine: '3' }} | ${null} + ${'with parallel and no right side'} | ${{ left: { old_line: 3, new_line: 5 } }} | ${false} | ${{ type: 'old', line: '3', oldLine: '3' }} | ${null} + ${'with parallel and no left side'} | ${{ right: { old_line: 3, new_line: 5 } }} | ${false} | ${null} | ${{ type: 'new', line: '5', newLine: '5' }} + ${'with parallel and right side'} | ${{ left: { old_line: 3 }, right: { new_line: 5 } }} | ${false} | ${{ type: 'old', line: '3', oldLine: '3' }} | ${{ type: 'new', line: '5', newLine: '5' }} + `('$desc, sets interop data attributes', ({ line, inline, leftSide, rightSide }) => { + const wrapper = createWrapper({ props: { line, inline } }); + + expect(findInteropAttributes(wrapper, '[data-testid="left-side"]')).toEqual(leftSide); + expect(findInteropAttributes(wrapper, '[data-testid="right-side"]')).toEqual(rightSide); + }); + }); }); diff --git a/spec/frontend/diffs/components/inline_diff_table_row_spec.js b/spec/frontend/diffs/components/inline_diff_table_row_spec.js index 28b3055b58c..66b63a7a1d0 100644 --- a/spec/frontend/diffs/components/inline_diff_table_row_spec.js +++ b/spec/frontend/diffs/components/inline_diff_table_row_spec.js @@ -3,6 +3,7 @@ import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue'; import { mapInline } from '~/diffs/components/diff_row_utils'; import InlineDiffTableRow from '~/diffs/components/inline_diff_table_row.vue'; import { createStore } from '~/mr_notes/stores'; +import { findInteropAttributes } from '../find_interop_attributes'; import discussionsMockData from '../mock_data/diff_discussions'; import diffFileMockData from '../mock_data/diff_file'; @@ -310,4 +311,16 @@ describe('InlineDiffTableRow', () => { }); }); }); + + describe('interoperability', () => { + it.each` + desc | line | expectation + ${'with type old'} | ${{ ...thisLine, type: 'old', old_line: 3, new_line: 5 }} | ${{ type: 'old', line: '3', oldLine: '3', newLine: '5' }} + ${'with type new'} | ${{ ...thisLine, type: 'new', old_line: 3, new_line: 5 }} | ${{ type: 'new', line: '5', oldLine: '3', newLine: '5' }} + `('$desc, sets interop data attributes', ({ line, expectation }) => { + createComponent({ line }); + + expect(findInteropAttributes(wrapper)).toEqual(expectation); + }); + }); }); diff --git a/spec/frontend/diffs/components/parallel_diff_table_row_spec.js b/spec/frontend/diffs/components/parallel_diff_table_row_spec.js index dbe8303077d..ed191d849fd 100644 --- a/spec/frontend/diffs/components/parallel_diff_table_row_spec.js +++ b/spec/frontend/diffs/components/parallel_diff_table_row_spec.js @@ -5,6 +5,7 @@ import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue'; import { mapParallel } from '~/diffs/components/diff_row_utils'; import ParallelDiffTableRow from '~/diffs/components/parallel_diff_table_row.vue'; import { createStore } from '~/mr_notes/stores'; +import { findInteropAttributes } from '../find_interop_attributes'; import discussionsMockData from '../mock_data/diff_discussions'; import diffFileMockData from '../mock_data/diff_file'; @@ -418,5 +419,27 @@ describe('ParallelDiffTableRow', () => { }); }); }); + + describe('interoperability', () => { + beforeEach(() => { + createComponent(); + }); + + it('adds old side interoperability data attributes', () => { + expect(findInteropAttributes(wrapper, '.line_content.left-side')).toEqual({ + type: 'old', + line: thisLine.left.old_line.toString(), + oldLine: thisLine.left.old_line.toString(), + }); + }); + + it('adds new side interoperability data attributes', () => { + expect(findInteropAttributes(wrapper, '.line_content.right-side')).toEqual({ + type: 'new', + line: thisLine.right.new_line.toString(), + newLine: thisLine.right.new_line.toString(), + }); + }); + }); }); }); diff --git a/spec/frontend/diffs/create_diffs_store.js b/spec/frontend/diffs/create_diffs_store.js index aacde99964c..e6a8b7a72ae 100644 --- a/spec/frontend/diffs/create_diffs_store.js +++ b/spec/frontend/diffs/create_diffs_store.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import Vuex from 'vuex'; +import batchCommentsModule from '~/batch_comments/stores/modules/batch_comments'; import diffsModule from '~/diffs/store/modules'; import notesModule from '~/notes/stores/modules'; @@ -10,6 +11,7 @@ export default function createDiffsStore() { modules: { diffs: diffsModule(), notes: notesModule(), + batchComments: batchCommentsModule(), }, }); } diff --git a/spec/frontend/diffs/find_interop_attributes.js b/spec/frontend/diffs/find_interop_attributes.js new file mode 100644 index 00000000000..d2266b20e16 --- /dev/null +++ b/spec/frontend/diffs/find_interop_attributes.js @@ -0,0 +1,20 @@ +export const findInteropAttributes = (parent, sel) => { + const target = sel ? parent.find(sel) : parent; + + if (!target.exists()) { + return null; + } + + const type = target.attributes('data-interop-type'); + + if (!type) { + return null; + } + + return { + type, + line: target.attributes('data-interop-line'), + oldLine: target.attributes('data-interop-old-line'), + newLine: target.attributes('data-interop-new-line'), + }; +}; diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index ed3210ecfaf..f46a42fae7a 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -80,7 +80,7 @@ describe('DiffsStoreActions', () => { jest.spyOn(utils, 'idleCallback').mockImplementation(() => null); ['requestAnimationFrame', 'requestIdleCallback'].forEach((method) => { global[method] = (cb) => { - cb(); + cb({ timeRemaining: () => 10 }); }; }); }); @@ -198,7 +198,7 @@ describe('DiffsStoreActions', () => { { type: types.VIEW_DIFF_FILE, payload: 'test2' }, { type: types.SET_RETRIEVING_BATCHES, payload: false }, ], - [], + [{ type: 'startRenderDiffsQueue' }, { type: 'startRenderDiffsQueue' }], done, ); }); @@ -251,6 +251,8 @@ describe('DiffsStoreActions', () => { { type: types.SET_LOADING, payload: false }, { type: types.SET_MERGE_REQUEST_DIFFS, payload: diffMetadata.merge_request_diffs }, { type: types.SET_DIFF_METADATA, payload: noFilesData }, + // Workers are synchronous in Jest environment (see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/58805) + { type: types.SET_TREE_DATA, payload: utils.generateTreeList(diffMetadata.diff_files) }, ], [], () => { @@ -1459,19 +1461,42 @@ describe('DiffsStoreActions', () => { }); describe('setFileByFile', () => { + const updateUserEndpoint = 'user/prefs'; + let putSpy; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + putSpy = jest.spyOn(axios, 'put'); + + mock.onPut(updateUserEndpoint).reply(200, {}); + }); + + afterEach(() => { + mock.restore(); + }); + it.each` value ${true} ${false} - `('commits SET_FILE_BY_FILE with the new value $value', ({ value }) => { - return testAction( - setFileByFile, - { fileByFile: value }, - { viewDiffsFileByFile: null }, - [{ type: types.SET_FILE_BY_FILE, payload: value }], - [], - ); - }); + `( + 'commits SET_FILE_BY_FILE and persists the File-by-File user preference with the new value $value', + async ({ value }) => { + await testAction( + setFileByFile, + { fileByFile: value }, + { + viewDiffsFileByFile: null, + endpointUpdateUser: updateUserEndpoint, + }, + [{ type: types.SET_FILE_BY_FILE, payload: value }], + [], + ); + + expect(putSpy).toHaveBeenCalledWith(updateUserEndpoint, { view_diffs_file_by_file: value }); + }, + ); }); describe('reviewFile', () => { diff --git a/spec/frontend/diffs/store/getters_spec.js b/spec/frontend/diffs/store/getters_spec.js index 04606b48662..2e3a66d5b01 100644 --- a/spec/frontend/diffs/store/getters_spec.js +++ b/spec/frontend/diffs/store/getters_spec.js @@ -377,32 +377,40 @@ describe('Diffs Module Getters', () => { }); describe('suggestionCommitMessage', () => { + let rootState; + beforeEach(() => { Object.assign(localState, { defaultSuggestionCommitMessage: '%{branch_name}%{project_path}%{project_name}%{username}%{user_full_name}%{file_paths}%{suggestions_count}%{files_count}', - branchName: 'branch', - projectPath: '/path', - projectName: 'name', - username: 'user', - userFullName: 'user userton', }); + rootState = { + page: { + mrMetadata: { + branch_name: 'branch', + project_path: '/path', + project_name: 'name', + username: 'user', + user_full_name: 'user userton', + }, + }, + }; }); it.each` - specialState | output - ${{}} | ${'branch/pathnameuseruser userton%{file_paths}%{suggestions_count}%{files_count}'} - ${{ userFullName: null }} | ${'branch/pathnameuser%{user_full_name}%{file_paths}%{suggestions_count}%{files_count}'} - ${{ username: null }} | ${'branch/pathname%{username}user userton%{file_paths}%{suggestions_count}%{files_count}'} - ${{ projectName: null }} | ${'branch/path%{project_name}useruser userton%{file_paths}%{suggestions_count}%{files_count}'} - ${{ projectPath: null }} | ${'branch%{project_path}nameuseruser userton%{file_paths}%{suggestions_count}%{files_count}'} - ${{ branchName: null }} | ${'%{branch_name}/pathnameuseruser userton%{file_paths}%{suggestions_count}%{files_count}'} + specialState | output + ${{}} | ${'branch/pathnameuseruser userton%{file_paths}%{suggestions_count}%{files_count}'} + ${{ user_full_name: null }} | ${'branch/pathnameuser%{user_full_name}%{file_paths}%{suggestions_count}%{files_count}'} + ${{ username: null }} | ${'branch/pathname%{username}user userton%{file_paths}%{suggestions_count}%{files_count}'} + ${{ project_name: null }} | ${'branch/path%{project_name}useruser userton%{file_paths}%{suggestions_count}%{files_count}'} + ${{ project_path: null }} | ${'branch%{project_path}nameuseruser userton%{file_paths}%{suggestions_count}%{files_count}'} + ${{ branch_name: null }} | ${'%{branch_name}/pathnameuseruser userton%{file_paths}%{suggestions_count}%{files_count}'} `( 'provides the correct "base" default commit message based on state ($specialState)', ({ specialState, output }) => { - Object.assign(localState, specialState); + Object.assign(rootState.page.mrMetadata, specialState); - expect(getters.suggestionCommitMessage(localState)()).toBe(output); + expect(getters.suggestionCommitMessage(localState, null, rootState)()).toBe(output); }, ); @@ -417,7 +425,9 @@ describe('Diffs Module Getters', () => { `( "properly overrides state values ($stateOverrides) if they're provided", ({ stateOverrides, output }) => { - expect(getters.suggestionCommitMessage(localState)(stateOverrides)).toBe(output); + expect(getters.suggestionCommitMessage(localState, null, rootState)(stateOverrides)).toBe( + output, + ); }, ); @@ -431,7 +441,9 @@ describe('Diffs Module Getters', () => { `( "fills in any missing interpolations ($providedValues) when they're provided at the getter callsite", ({ providedValues, output }) => { - expect(getters.suggestionCommitMessage(localState)(providedValues)).toBe(output); + expect(getters.suggestionCommitMessage(localState, null, rootState)(providedValues)).toBe( + output, + ); }, ); }); diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js index a8ae759e693..b549ca42634 100644 --- a/spec/frontend/diffs/store/mutations_spec.js +++ b/spec/frontend/diffs/store/mutations_spec.js @@ -7,15 +7,17 @@ import diffFileMockData from '../mock_data/diff_file'; describe('DiffsStoreMutations', () => { describe('SET_BASE_CONFIG', () => { - it('should set endpoint and project path', () => { + it.each` + prop | value + ${'endpoint'} | ${'/diffs/endpoint'} + ${'projectPath'} | ${'/root/project'} + ${'endpointUpdateUser'} | ${'/user/preferences'} + `('should set the $prop property into state', ({ prop, value }) => { const state = {}; - const endpoint = '/diffs/endpoint'; - const projectPath = '/root/project'; - mutations[types.SET_BASE_CONFIG](state, { endpoint, projectPath }); + mutations[types.SET_BASE_CONFIG](state, { [prop]: value }); - expect(state.endpoint).toEqual(endpoint); - expect(state.projectPath).toEqual(projectPath); + expect(state[prop]).toEqual(value); }); }); diff --git a/spec/frontend/diffs/utils/interoperability_spec.js b/spec/frontend/diffs/utils/interoperability_spec.js new file mode 100644 index 00000000000..2557e83cb4c --- /dev/null +++ b/spec/frontend/diffs/utils/interoperability_spec.js @@ -0,0 +1,67 @@ +import { + getInteropInlineAttributes, + getInteropNewSideAttributes, + getInteropOldSideAttributes, + ATTR_TYPE, + ATTR_LINE, + ATTR_NEW_LINE, + ATTR_OLD_LINE, +} from '~/diffs/utils/interoperability'; + +describe('~/diffs/utils/interoperability', () => { + describe('getInteropInlineAttributes', () => { + it.each([ + ['with null input', { input: null, output: null }], + [ + 'with type=old input', + { + input: { type: 'old', old_line: 3, new_line: 5 }, + output: { [ATTR_TYPE]: 'old', [ATTR_LINE]: 3, [ATTR_OLD_LINE]: 3, [ATTR_NEW_LINE]: 5 }, + }, + ], + [ + 'with type=old-nonewline input', + { + input: { type: 'old-nonewline', old_line: 3, new_line: 5 }, + output: { [ATTR_TYPE]: 'old', [ATTR_LINE]: 3, [ATTR_OLD_LINE]: 3, [ATTR_NEW_LINE]: 5 }, + }, + ], + [ + 'with type=new input', + { + input: { type: 'new', old_line: 3, new_line: 5 }, + output: { [ATTR_TYPE]: 'new', [ATTR_LINE]: 5, [ATTR_OLD_LINE]: 3, [ATTR_NEW_LINE]: 5 }, + }, + ], + [ + 'with type=bogus input', + { + input: { type: 'bogus', old_line: 3, new_line: 5 }, + output: { [ATTR_TYPE]: 'new', [ATTR_LINE]: 5, [ATTR_OLD_LINE]: 3, [ATTR_NEW_LINE]: 5 }, + }, + ], + ])('%s', (desc, { input, output }) => { + expect(getInteropInlineAttributes(input)).toEqual(output); + }); + }); + + describe('getInteropOldSideAttributes', () => { + it.each` + input | output + ${null} | ${null} + ${{ old_line: 2 }} | ${{ [ATTR_TYPE]: 'old', [ATTR_LINE]: 2, [ATTR_OLD_LINE]: 2 }} + `('with input=$input', ({ input, output }) => { + expect(getInteropOldSideAttributes(input)).toEqual(output); + }); + }); + + describe('getInteropNewSideAttributes', () => { + it.each` + input | output + ${null} | ${null} + ${{ new_line: 2 }} | ${{ [ATTR_TYPE]: 'new', [ATTR_LINE]: 2, [ATTR_NEW_LINE]: 2 }} + `('with input=$input', ({ input, output }) => { + expect(getInteropNewSideAttributes(input)).toEqual(output); + }); + }); +}); diff --git a/spec/frontend/editor/editor_lite_extension_base_spec.js b/spec/frontend/editor/editor_lite_extension_base_spec.js index 5490e9dc7b5..1ae8c70c741 100644 --- a/spec/frontend/editor/editor_lite_extension_base_spec.js +++ b/spec/frontend/editor/editor_lite_extension_base_spec.js @@ -1,44 +1,247 @@ -import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION } from '~/editor/constants'; +import { Range } from 'monaco-editor'; +import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; +import { + ERROR_INSTANCE_REQUIRED_FOR_EXTENSION, + EDITOR_TYPE_CODE, + EDITOR_TYPE_DIFF, +} from '~/editor/constants'; import { EditorLiteExtension } from '~/editor/extensions/editor_lite_extension_base'; describe('The basis for an Editor Lite extension', () => { + const defaultLine = 3; let ext; + let event; + const defaultOptions = { foo: 'bar' }; + const findLine = (num) => { + return document.querySelector(`.line-numbers:nth-child(${num})`); + }; + const generateLines = () => { + let res = ''; + for (let line = 1, lines = 5; line <= lines; line += 1) { + res += `<div class="line-numbers">${line}</div>`; + } + return res; + }; + const generateEventMock = ({ line = defaultLine, el = null } = {}) => { + return { + target: { + element: el || findLine(line), + position: { + lineNumber: line, + }, + }, + }; + }; + + beforeEach(() => { + setFixtures(generateLines()); + event = generateEventMock(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it.each` + description | instance | options + ${'accepts configuration options and instance'} | ${{}} | ${defaultOptions} + ${'leaves instance intact if no options are passed'} | ${{}} | ${undefined} + ${'does not fail if both instance and the options are omitted'} | ${undefined} | ${undefined} + ${'throws if only options are passed'} | ${undefined} | ${defaultOptions} + `('$description', ({ instance, options } = {}) => { + const originalInstance = { ...instance }; - it.each` - description | instance | options - ${'accepts configuration options and instance'} | ${{}} | ${defaultOptions} - ${'leaves instance intact if no options are passed'} | ${{}} | ${undefined} - ${'does not fail if both instance and the options are omitted'} | ${undefined} | ${undefined} - ${'throws if only options are passed'} | ${undefined} | ${defaultOptions} - `('$description', ({ instance, options } = {}) => { - const originalInstance = { ...instance }; - - if (instance) { - if (options) { - Object.entries(options).forEach((prop) => { - expect(instance[prop]).toBeUndefined(); - }); - // Both instance and options are passed - ext = new EditorLiteExtension({ instance, ...options }); - Object.entries(options).forEach(([prop, value]) => { - expect(ext[prop]).toBeUndefined(); - expect(instance[prop]).toBe(value); - }); + if (instance) { + if (options) { + Object.entries(options).forEach((prop) => { + expect(instance[prop]).toBeUndefined(); + }); + // Both instance and options are passed + ext = new EditorLiteExtension({ instance, ...options }); + Object.entries(options).forEach(([prop, value]) => { + expect(ext[prop]).toBeUndefined(); + expect(instance[prop]).toBe(value); + }); + } else { + ext = new EditorLiteExtension({ instance }); + expect(instance).toEqual(originalInstance); + } + } else if (options) { + // Options are passed without instance + expect(() => { + ext = new EditorLiteExtension({ ...options }); + }).toThrow(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION); } else { - ext = new EditorLiteExtension({ instance }); - expect(instance).toEqual(originalInstance); + // Neither options nor instance are passed + expect(() => { + ext = new EditorLiteExtension(); + }).not.toThrow(); } - } else if (options) { - // Options are passed without instance - expect(() => { - ext = new EditorLiteExtension({ ...options }); - }).toThrow(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION); - } else { - // Neither options nor instance are passed - expect(() => { - ext = new EditorLiteExtension(); - }).not.toThrow(); - } + }); + + it('initializes the line highlighting', () => { + const spy = jest.spyOn(EditorLiteExtension, 'highlightLines'); + ext = new EditorLiteExtension({ instance: {} }); + expect(spy).toHaveBeenCalled(); + }); + + it('sets up the line linking for code instance', () => { + const spy = jest.spyOn(EditorLiteExtension, 'setupLineLinking'); + const instance = { + getEditorType: jest.fn().mockReturnValue(EDITOR_TYPE_CODE), + onMouseMove: jest.fn(), + onMouseDown: jest.fn(), + }; + ext = new EditorLiteExtension({ instance }); + expect(spy).toHaveBeenCalledWith(instance); + }); + + it('does not set up the line linking for diff instance', () => { + const spy = jest.spyOn(EditorLiteExtension, 'setupLineLinking'); + const instance = { + getEditorType: jest.fn().mockReturnValue(EDITOR_TYPE_DIFF), + }; + ext = new EditorLiteExtension({ instance }); + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('highlightLines', () => { + const revealSpy = jest.fn(); + const decorationsSpy = jest.fn(); + const instance = { + revealLineInCenter: revealSpy, + deltaDecorations: decorationsSpy, + }; + const defaultDecorationOptions = { isWholeLine: true, className: 'active-line-text' }; + + useFakeRequestAnimationFrame(); + + beforeEach(() => { + delete window.location; + window.location = new URL(`https://localhost`); + }); + + afterEach(() => { + window.location.hash = ''; + }); + + it.each` + desc | hash | shouldReveal | expectedRange + ${'properly decorates a single line'} | ${'#L10'} | ${true} | ${[10, 1, 10, 1]} + ${'properly decorates multiple lines'} | ${'#L7-42'} | ${true} | ${[7, 1, 42, 1]} + ${'correctly highlights if lines are reversed'} | ${'#L42-7'} | ${true} | ${[7, 1, 42, 1]} + ${'highlights one line if start/end are the same'} | ${'#L7-7'} | ${true} | ${[7, 1, 7, 1]} + ${'does not highlight if there is no hash'} | ${''} | ${false} | ${null} + ${'does not highlight if the hash is undefined'} | ${undefined} | ${false} | ${null} + ${'does not highlight if hash is incomplete 1'} | ${'#L'} | ${false} | ${null} + ${'does not highlight if hash is incomplete 2'} | ${'#L-'} | ${false} | ${null} + `('$desc', ({ hash, shouldReveal, expectedRange } = {}) => { + window.location.hash = hash; + EditorLiteExtension.highlightLines(instance); + if (!shouldReveal) { + expect(revealSpy).not.toHaveBeenCalled(); + expect(decorationsSpy).not.toHaveBeenCalled(); + } else { + expect(revealSpy).toHaveBeenCalledWith(expectedRange[0]); + expect(decorationsSpy).toHaveBeenCalledWith( + [], + [ + { + range: new Range(...expectedRange), + options: defaultDecorationOptions, + }, + ], + ); + } + }); + + it('stores the line decorations on the instance', () => { + decorationsSpy.mockReturnValue('foo'); + window.location.hash = '#L10'; + expect(instance.lineDecorations).toBeUndefined(); + EditorLiteExtension.highlightLines(instance); + expect(instance.lineDecorations).toBe('foo'); + }); + }); + + describe('setupLineLinking', () => { + const instance = { + onMouseMove: jest.fn(), + onMouseDown: jest.fn(), + deltaDecorations: jest.fn(), + lineDecorations: 'foo', + }; + + beforeEach(() => { + EditorLiteExtension.onMouseMoveHandler(event); // generate the anchor + }); + + it.each` + desc | spy + ${'onMouseMove'} | ${instance.onMouseMove} + ${'onMouseDown'} | ${instance.onMouseDown} + `('sets up the $desc listener', ({ spy } = {}) => { + EditorLiteExtension.setupLineLinking(instance); + expect(spy).toHaveBeenCalled(); + }); + + it.each` + desc | eventTrigger | shouldRemove + ${'does not remove the line decorations if the event is triggered on a wrong node'} | ${null} | ${false} + ${'removes existing line decorations when clicking a line number'} | ${'.link-anchor'} | ${true} + `('$desc', ({ eventTrigger, shouldRemove } = {}) => { + event = generateEventMock({ el: eventTrigger ? document.querySelector(eventTrigger) : null }); + instance.onMouseDown.mockImplementation((fn) => { + fn(event); + }); + + EditorLiteExtension.setupLineLinking(instance); + if (shouldRemove) { + expect(instance.deltaDecorations).toHaveBeenCalledWith(instance.lineDecorations, []); + } else { + expect(instance.deltaDecorations).not.toHaveBeenCalled(); + } + }); + }); + + describe('onMouseMoveHandler', () => { + it('stops propagation for contextmenu event on the generated anchor', () => { + EditorLiteExtension.onMouseMoveHandler(event); + const anchor = findLine(defaultLine).querySelector('a'); + const contextMenuEvent = new Event('contextmenu'); + + jest.spyOn(contextMenuEvent, 'stopPropagation'); + anchor.dispatchEvent(contextMenuEvent); + + expect(contextMenuEvent.stopPropagation).toHaveBeenCalled(); + }); + + it('creates an anchor if it does not exist yet', () => { + expect(findLine(defaultLine).querySelector('a')).toBe(null); + EditorLiteExtension.onMouseMoveHandler(event); + expect(findLine(defaultLine).querySelector('a')).not.toBe(null); + }); + + it('does not create a new anchor if it exists', () => { + EditorLiteExtension.onMouseMoveHandler(event); + expect(findLine(defaultLine).querySelector('a')).not.toBe(null); + + EditorLiteExtension.createAnchor = jest.fn(); + EditorLiteExtension.onMouseMoveHandler(event); + expect(EditorLiteExtension.createAnchor).not.toHaveBeenCalled(); + expect(findLine(defaultLine).querySelectorAll('a')).toHaveLength(1); + }); + + it('does not create a link if the event is triggered on a wrong node', () => { + setFixtures('<div class="wrong-class">3</div>'); + EditorLiteExtension.createAnchor = jest.fn(); + const wrongEvent = generateEventMock({ el: document.querySelector('.wrong-class') }); + + EditorLiteExtension.onMouseMoveHandler(wrongEvent); + expect(EditorLiteExtension.createAnchor).not.toHaveBeenCalled(); + }); }); }); diff --git a/spec/frontend/emoji/awards_app/store/actions_spec.js b/spec/frontend/emoji/awards_app/store/actions_spec.js new file mode 100644 index 00000000000..dac4fded260 --- /dev/null +++ b/spec/frontend/emoji/awards_app/store/actions_spec.js @@ -0,0 +1,155 @@ +import * as Sentry from '@sentry/browser'; +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import * as actions from '~/emoji/awards_app/store/actions'; +import axios from '~/lib/utils/axios_utils'; + +jest.mock('@sentry/browser'); + +describe('Awards app actions', () => { + describe('setInitialData', () => { + it('commits SET_INITIAL_DATA', async () => { + await testAction( + actions.setInitialData, + { path: 'https://gitlab.com' }, + {}, + [{ type: 'SET_INITIAL_DATA', payload: { path: 'https://gitlab.com' } }], + [], + ); + }); + }); + + describe('fetchAwards', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('success', () => { + beforeEach(() => { + mock + .onGet('/awards', { params: { per_page: 100, page: '1' } }) + .reply(200, ['thumbsup'], { 'x-next-page': '2' }); + mock.onGet('/awards', { params: { per_page: 100, page: '2' } }).reply(200, ['thumbsdown']); + }); + + it('commits FETCH_AWARDS_SUCCESS', async () => { + await testAction( + actions.fetchAwards, + '1', + { path: '/awards' }, + [{ type: 'FETCH_AWARDS_SUCCESS', payload: ['thumbsup'] }], + [{ type: 'fetchAwards', payload: '2' }], + ); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onGet('/awards').reply(500); + }); + + it('calls Sentry.captureException', async () => { + await testAction(actions.fetchAwards, null, { path: '/awards' }, [], [], () => { + expect(Sentry.captureException).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('toggleAward', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('adding new award', () => { + describe('success', () => { + beforeEach(() => { + mock.onPost('/awards').reply(200, { id: 1 }); + }); + + it('commits ADD_NEW_AWARD', async () => { + testAction(actions.toggleAward, null, { path: '/awards', awards: [] }, [ + { type: 'ADD_NEW_AWARD', payload: { id: 1 } }, + ]); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onPost('/awards').reply(500); + }); + + it('calls Sentry.captureException', async () => { + await testAction( + actions.toggleAward, + null, + { path: '/awards', awards: [] }, + [], + [], + () => { + expect(Sentry.captureException).toHaveBeenCalled(); + }, + ); + }); + }); + }); + + describe('removing a award', () => { + const mockData = { id: 1, name: 'thumbsup', user: { id: 1 } }; + + describe('success', () => { + beforeEach(() => { + mock.onDelete('/awards/1').reply(200); + }); + + it('commits REMOVE_AWARD', async () => { + testAction( + actions.toggleAward, + 'thumbsup', + { + path: '/awards', + currentUserId: 1, + awards: [mockData], + }, + [{ type: 'REMOVE_AWARD', payload: 1 }], + ); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onDelete('/awards/1').reply(500); + }); + + it('calls Sentry.captureException', async () => { + await testAction( + actions.toggleAward, + 'thumbsup', + { + path: '/awards', + currentUserId: 1, + awards: [mockData], + }, + [], + [], + () => { + expect(Sentry.captureException).toHaveBeenCalled(); + }, + ); + }); + }); + }); + }); +}); diff --git a/spec/frontend/emoji/awards_app/store/mutations_spec.js b/spec/frontend/emoji/awards_app/store/mutations_spec.js new file mode 100644 index 00000000000..dd32c3a4445 --- /dev/null +++ b/spec/frontend/emoji/awards_app/store/mutations_spec.js @@ -0,0 +1,65 @@ +import { + SET_INITIAL_DATA, + FETCH_AWARDS_SUCCESS, + ADD_NEW_AWARD, + REMOVE_AWARD, +} from '~/emoji/awards_app/store/mutation_types'; +import mutations from '~/emoji/awards_app/store/mutations'; + +describe('Awards app mutations', () => { + describe('SET_INITIAL_DATA', () => { + it('sets initial data', () => { + const state = {}; + + mutations[SET_INITIAL_DATA](state, { + path: 'https://gitlab.com', + currentUserId: 1, + canAwardEmoji: true, + }); + + expect(state).toEqual({ + path: 'https://gitlab.com', + currentUserId: 1, + canAwardEmoji: true, + }); + }); + }); + + describe('FETCH_AWARDS_SUCCESS', () => { + it('sets awards', () => { + const state = { awards: [] }; + + mutations[FETCH_AWARDS_SUCCESS](state, ['thumbsup']); + + expect(state.awards).toEqual(['thumbsup']); + }); + + it('does not overwrite previously set awards', () => { + const state = { awards: ['thumbsup'] }; + + mutations[FETCH_AWARDS_SUCCESS](state, ['thumbsdown']); + + expect(state.awards).toEqual(['thumbsup', 'thumbsdown']); + }); + }); + + describe('ADD_NEW_AWARD', () => { + it('adds new award to array', () => { + const state = { awards: ['thumbsup'] }; + + mutations[ADD_NEW_AWARD](state, 'thumbsdown'); + + expect(state.awards).toEqual(['thumbsup', 'thumbsdown']); + }); + }); + + describe('REMOVE_AWARD', () => { + it('removes award from array', () => { + const state = { awards: [{ id: 1 }, { id: 2 }] }; + + mutations[REMOVE_AWARD](state, 1); + + expect(state.awards).toEqual([{ id: 2 }]); + }); + }); +}); diff --git a/spec/frontend/environments/enable_review_app_modal_spec.js b/spec/frontend/environments/enable_review_app_modal_spec.js index f5063cff620..9a3f13f19d5 100644 --- a/spec/frontend/environments/enable_review_app_modal_spec.js +++ b/spec/frontend/environments/enable_review_app_modal_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import EnableReviewAppButton from '~/environments/components/enable_review_app_modal.vue'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; @@ -11,15 +12,25 @@ describe('Enable Review App Button', () => { describe('renders the modal', () => { beforeEach(() => { - wrapper = shallowMount(EnableReviewAppButton, { - propsData: { - modalId: 'fake-id', - }, - }); + wrapper = extendedWrapper( + shallowMount(EnableReviewAppButton, { + propsData: { + modalId: 'fake-id', + }, + provide: { + defaultBranchName: 'main', + }, + }), + ); + }); + + it('renders the defaultBranchName copy', () => { + const findCopyString = () => wrapper.findByTestId('enable-review-app-copy-string'); + expect(findCopyString().text()).toContain('- main'); }); it('renders the copyToClipboard button', () => { - expect(wrapper.find(ModalCopyButton).exists()).toBe(true); + expect(wrapper.findComponent(ModalCopyButton).exists()).toBe(true); }); }); }); diff --git a/spec/frontend/error_tracking/components/error_tracking_list_spec.js b/spec/frontend/error_tracking/components/error_tracking_list_spec.js index c6ce236af01..c0c542ae587 100644 --- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js +++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js @@ -52,7 +52,6 @@ describe('ErrorTrackingList', () => { beforeEach(() => { actions = { - getErrorList: () => {}, startPolling: jest.fn(), restartPolling: jest.fn().mockName('restartPolling'), addRecentSearch: jest.fn(), diff --git a/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js b/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js index 7ebaf0c3f2a..f02a261f323 100644 --- a/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js +++ b/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js @@ -44,13 +44,13 @@ describe('error tracking settings form', () => { const pageText = wrapper.text(); expect(pageText).toContain( - "If you self-host Sentry, enter the full URL of your Sentry instance. If you're using Sentry's hosted solution, enter https://sentry.io", + "If you self-host Sentry, enter your Sentry instance's full URL. If you use Sentry's hosted solution, enter https://sentry.io", ); expect(pageText).toContain( - "After adding your Auth Token, use the 'Connect' button to load projects", + 'After adding your Auth Token, select the Connect button to load projects.', ); - expect(pageText).not.toContain('Connection has failed. Re-check Auth Token and try again'); + expect(pageText).not.toContain('Connection failed. Check Auth Token and try again.'); expect(wrapper.findAll(GlFormInput).at(0).attributes('placeholder')).toContain( 'https://mysentryserver.com', ); @@ -80,9 +80,7 @@ describe('error tracking settings form', () => { }); it('does not show an error', () => { - expect(wrapper.text()).not.toContain( - 'Connection has failed. Re-check Auth Token and try again', - ); + expect(wrapper.text()).not.toContain('Connection failed. Check Auth Token and try again.'); }); }); @@ -96,7 +94,7 @@ describe('error tracking settings form', () => { }); it('shows an error', () => { - expect(wrapper.text()).toContain('Connection has failed. Re-check Auth Token and try again'); + expect(wrapper.text()).toContain('Connection failed. Check Auth Token and try again.'); }); }); }); diff --git a/spec/frontend/error_tracking_settings/store/getters_spec.js b/spec/frontend/error_tracking_settings/store/getters_spec.js index b135fdee40b..4bb8d38e294 100644 --- a/spec/frontend/error_tracking_settings/store/getters_spec.js +++ b/spec/frontend/error_tracking_settings/store/getters_spec.js @@ -78,7 +78,7 @@ describe('Error Tracking Settings - Getters', () => { describe('projectSelectionLabel', () => { it('should show the correct message when the token is empty', () => { expect(getters.projectSelectionLabel(state)).toEqual( - 'To enable project selection, enter a valid Auth Token', + 'To enable project selection, enter a valid Auth Token.', ); }); @@ -86,7 +86,7 @@ describe('Error Tracking Settings - Getters', () => { state.token = 'test-token'; expect(getters.projectSelectionLabel(state)).toEqual( - "Click 'Connect' to re-establish the connection to Sentry and activate the dropdown.", + 'Click Connect to reestablish the connection to Sentry and activate the dropdown.', ); }); }); diff --git a/spec/frontend/experimentation/components/experiment_spec.js b/spec/frontend/experimentation/components/experiment_spec.js new file mode 100644 index 00000000000..dbc7da5c535 --- /dev/null +++ b/spec/frontend/experimentation/components/experiment_spec.js @@ -0,0 +1,72 @@ +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import ExperimentComponent from '~/experimentation/components/experiment.vue'; + +const defaultProps = { name: 'experiment_name' }; +const defaultSlots = { + candidate: `<p>Candidate</p>`, + control: `<p>Control</p>`, +}; + +describe('ExperimentComponent', () => { + const oldGon = window.gon; + let wrapper; + + const createComponent = (propsData = defaultProps, slots = defaultSlots) => { + wrapper = extendedWrapper(shallowMount(ExperimentComponent, { propsData, slots })); + }; + + const mockVariant = (expectedVariant) => { + window.gon = { experiment: { experiment_name: { variant: expectedVariant } } }; + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + window.gon = oldGon; + }); + + describe('when variant and experiment is set', () => { + it('renders control when it is the active variant', () => { + mockVariant('control'); + + createComponent(); + + expect(wrapper.text()).toBe('Control'); + }); + + it('renders candidate when it is the active variant', () => { + mockVariant('candidate'); + + createComponent(); + + expect(wrapper.text()).toBe('Candidate'); + }); + }); + + describe('when variant or experiment is not set', () => { + it('renders the control slot when no variant is defined', () => { + mockVariant(undefined); + + createComponent(); + + expect(wrapper.text()).toBe('Control'); + }); + + it('renders nothing when behavior is not set for variant', () => { + mockVariant('non-existing-variant'); + + createComponent(defaultProps, { control: `<p>First</p>`, other: `<p>Other</p>` }); + + expect(wrapper.text()).toBe(''); + }); + + it('renders nothing when there are no slots', () => { + mockVariant('control'); + + createComponent(defaultProps, {}); + + expect(wrapper.text()).toBe(''); + }); + }); +}); diff --git a/spec/frontend/experimentation/utils_spec.js b/spec/frontend/experimentation/utils_spec.js index 87dd2d595ba..ec09bbab349 100644 --- a/spec/frontend/experimentation/utils_spec.js +++ b/spec/frontend/experimentation/utils_spec.js @@ -1,38 +1,97 @@ +import { assignGitlabExperiment } from 'helpers/experimentation_helper'; +import { DEFAULT_VARIANT, CANDIDATE_VARIANT } from '~/experimentation/constants'; import * as experimentUtils from '~/experimentation/utils'; -const TEST_KEY = 'abc'; - describe('experiment Utilities', () => { - const oldGon = window.gon; - - afterEach(() => { - window.gon = oldGon; - }); + const TEST_KEY = 'abc'; describe('getExperimentData', () => { - it.each` - gon | input | output - ${{ experiment: { [TEST_KEY]: '_data_' } }} | ${[TEST_KEY]} | ${'_data_'} - ${{}} | ${[TEST_KEY]} | ${undefined} - `('with input=$input and gon=$gon, returns $output', ({ gon, input, output }) => { - window.gon = gon; + describe.each` + gon | input | output + ${[TEST_KEY, '_data_']} | ${[TEST_KEY]} | ${{ variant: '_data_' }} + ${[]} | ${[TEST_KEY]} | ${undefined} + `('with input=$input and gon=$gon', ({ gon, input, output }) => { + assignGitlabExperiment(...gon); - expect(experimentUtils.getExperimentData(...input)).toEqual(output); + it(`returns ${output}`, () => { + expect(experimentUtils.getExperimentData(...input)).toEqual(output); + }); }); }); describe('isExperimentVariant', () => { + describe.each` + gon | input | output + ${[TEST_KEY, DEFAULT_VARIANT]} | ${[TEST_KEY, DEFAULT_VARIANT]} | ${true} + ${[TEST_KEY, '_variant_name']} | ${[TEST_KEY, '_variant_name']} | ${true} + ${[TEST_KEY, '_variant_name']} | ${[TEST_KEY, '_bogus_name']} | ${false} + ${[TEST_KEY, '_variant_name']} | ${['boguskey', '_variant_name']} | ${false} + ${[]} | ${[TEST_KEY, '_variant_name']} | ${false} + `('with input=$input and gon=$gon', ({ gon, input, output }) => { + assignGitlabExperiment(...gon); + + it(`returns ${output}`, () => { + expect(experimentUtils.isExperimentVariant(...input)).toEqual(output); + }); + }); + }); + + describe('experiment', () => { + const controlSpy = jest.fn(); + const candidateSpy = jest.fn(); + const getUpStandUpSpy = jest.fn(); + + const variants = { + use: controlSpy, + try: candidateSpy, + get_up_stand_up: getUpStandUpSpy, + }; + + describe('when there is no experiment data', () => { + it('calls control variant', () => { + experimentUtils.experiment('marley', variants); + expect(controlSpy).toHaveBeenCalled(); + }); + }); + + describe('when experiment variant is "control"', () => { + assignGitlabExperiment('marley', DEFAULT_VARIANT); + + it('calls the control variant', () => { + experimentUtils.experiment('marley', variants); + expect(controlSpy).toHaveBeenCalled(); + }); + }); + + describe('when experiment variant is "candidate"', () => { + assignGitlabExperiment('marley', CANDIDATE_VARIANT); + + it('calls the candidate variant', () => { + experimentUtils.experiment('marley', variants); + expect(candidateSpy).toHaveBeenCalled(); + }); + }); + + describe('when experiment variant is "get_up_stand_up"', () => { + assignGitlabExperiment('marley', 'get_up_stand_up'); + + it('calls the get-up-stand-up variant', () => { + experimentUtils.experiment('marley', variants); + expect(getUpStandUpSpy).toHaveBeenCalled(); + }); + }); + }); + + describe('getExperimentVariant', () => { it.each` - gon | input | output - ${{ experiment: { [TEST_KEY]: { variant: 'control' } } }} | ${[TEST_KEY, 'control']} | ${true} - ${{ experiment: { [TEST_KEY]: { variant: '_variant_name' } } }} | ${[TEST_KEY, '_variant_name']} | ${true} - ${{ experiment: { [TEST_KEY]: { variant: '_variant_name' } } }} | ${[TEST_KEY, '_bogus_name']} | ${false} - ${{ experiment: { [TEST_KEY]: { variant: '_variant_name' } } }} | ${['boguskey', '_variant_name']} | ${false} - ${{}} | ${[TEST_KEY, '_variant_name']} | ${false} + gon | input | output + ${{ experiment: { [TEST_KEY]: { variant: DEFAULT_VARIANT } } }} | ${[TEST_KEY]} | ${DEFAULT_VARIANT} + ${{ experiment: { [TEST_KEY]: { variant: CANDIDATE_VARIANT } } }} | ${[TEST_KEY]} | ${CANDIDATE_VARIANT} + ${{}} | ${[TEST_KEY]} | ${DEFAULT_VARIANT} `('with input=$input and gon=$gon, returns $output', ({ gon, input, output }) => { window.gon = gon; - expect(experimentUtils.isExperimentVariant(...input)).toEqual(output); + expect(experimentUtils.getExperimentVariant(...input)).toEqual(output); }); }); }); diff --git a/spec/frontend/feature_flags/components/form_spec.js b/spec/frontend/feature_flags/components/form_spec.js index a05e23a4250..00d557c11cf 100644 --- a/spec/frontend/feature_flags/components/form_spec.js +++ b/spec/frontend/feature_flags/components/form_spec.js @@ -123,6 +123,10 @@ describe('feature flag form', () => { }); }); + it('has label', () => { + expect(findGlToggle().props('label')).toBe(Form.i18n.statusLabel); + }); + it('should be disabled if the feature flag is not active', (done) => { wrapper.setProps({ active: false }); wrapper.vm.$nextTick(() => { diff --git a/spec/frontend/feature_highlight/feature_highlight_popover_spec.js b/spec/frontend/feature_highlight/feature_highlight_popover_spec.js index 1d558366ce8..e5e3974e103 100644 --- a/spec/frontend/feature_highlight/feature_highlight_popover_spec.js +++ b/spec/frontend/feature_highlight/feature_highlight_popover_spec.js @@ -41,7 +41,6 @@ describe('feature_highlight/feature_highlight_popover', () => { expect(findPopover().props()).toMatchObject({ target: POPOVER_TARGET_ID, cssClasses: ['feature-highlight-popover'], - triggers: 'hover', container: 'body', placement: 'right', boundary: 'viewport', diff --git a/spec/frontend/fixtures/api_markdown.rb b/spec/frontend/fixtures/api_markdown.rb new file mode 100644 index 00000000000..e012d922aad --- /dev/null +++ b/spec/frontend/fixtures/api_markdown.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::MergeRequests, '(JavaScript fixtures)', type: :request do + include ApiHelpers + include JavaScriptFixturesHelpers + + fixture_subdir = 'api/markdown' + + before(:all) do + clean_frontend_fixtures(fixture_subdir) + end + + markdown_examples = begin + yaml_file_path = File.expand_path('api_markdown.yml', __dir__) + yaml = File.read(yaml_file_path) + YAML.safe_load(yaml, symbolize_names: true) + end + + markdown_examples.each do |markdown_example| + name = markdown_example.fetch(:name) + + context "for #{name}" do + let(:markdown) { markdown_example.fetch(:markdown) } + + it "#{fixture_subdir}/#{name}.json" do + post api("/markdown"), params: { text: markdown } + + expect(response).to be_successful + end + end + end +end diff --git a/spec/frontend/fixtures/api_markdown.yml b/spec/frontend/fixtures/api_markdown.yml new file mode 100644 index 00000000000..a83d5374e2c --- /dev/null +++ b/spec/frontend/fixtures/api_markdown.yml @@ -0,0 +1,50 @@ +# This data file drives the specs in +# spec/frontend/fixtures/api_markdown.rb and +# spec/frontend/rich_text_editor/extensions/markdown_processing_spec.js +--- +- name: bold + markdown: '**bold**' +- name: emphasis + markdown: '_emphasized text_' +- name: inline_code + markdown: '`code`' +- name: link + markdown: '[GitLab](https://gitlab.com)' +- name: code_block + markdown: |- + ```javascript + console.log('hello world') + ``` +- name: headings + markdown: |- + # Heading 1 + + ## Heading 2 + + ### Heading 3 + + #### Heading 4 + + ##### Heading 5 + + ###### Heading 6 +- name: blockquote + markdown: |- + > This is a blockquote + > + > This is another one +- name: thematic_break + markdown: |- + --- +- name: bullet_list + 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: image + markdown: '![alt text](https://gitlab.com/logo.png)' diff --git a/spec/frontend/fixtures/autocomplete.rb b/spec/frontend/fixtures/autocomplete.rb new file mode 100644 index 00000000000..8983e241aa5 --- /dev/null +++ b/spec/frontend/fixtures/autocomplete.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::AutocompleteController, '(JavaScript fixtures)', type: :controller do + include JavaScriptFixturesHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group, name: 'frontend-fixtures') } + + let(:project) { create(:project, namespace: group, path: 'autocomplete-project') } + let(:merge_request) { create(:merge_request, source_project: project, author: user) } + + before(:all) do + clean_frontend_fixtures('autocomplete/') + end + + before do + group.add_owner(user) + sign_in(user) + end + + it 'autocomplete/users.json' do + 20.times do + user = create(:user) + project.add_developer(user) + end + + get :users, + format: :json, + params: { + project_id: project.id, + active: true, + current_user: true, + author: merge_request.author.id, + merge_request_iid: merge_request.iid + } + + expect(response).to be_successful + end +end diff --git a/spec/frontend/fixtures/issues.rb b/spec/frontend/fixtures/issues.rb index d6f6ed97626..a027247bd0d 100644 --- a/spec/frontend/fixtures/issues.rb +++ b/spec/frontend/fixtures/issues.rb @@ -16,8 +16,6 @@ RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', type: :contr end before do - stub_feature_flags(boards_filtered_search: false) - project.add_maintainer(user) sign_in(user) end diff --git a/spec/frontend/fixtures/merge_requests_diffs.rb b/spec/frontend/fixtures/merge_requests_diffs.rb index 5ad4176f7b8..edf1fcf3c0a 100644 --- a/spec/frontend/fixtures/merge_requests_diffs.rb +++ b/spec/frontend/fixtures/merge_requests_diffs.rb @@ -25,6 +25,10 @@ RSpec.describe Projects::MergeRequests::DiffsController, '(JavaScript fixtures)' end before do + # Create a user that matches the project.commit author + # This is so that the "author" information will be populated + create(:user, email: project.commit.author_email, name: project.commit.author_name) + sign_in(user) end @@ -33,17 +37,21 @@ RSpec.describe Projects::MergeRequests::DiffsController, '(JavaScript fixtures)' end it 'merge_request_diffs/with_commit.json' do - # Create a user that matches the project.commit author - # This is so that the "author" information will be populated - create(:user, email: project.commit.author_email, name: project.commit.author_name) - render_merge_request(merge_request, commit_id: project.commit.sha) end + it 'merge_request_diffs/diffs_metadata.json' do + render_merge_request(merge_request, action: :diffs_metadata) + end + + it 'merge_request_diffs/diffs_batch.json' do + render_merge_request(merge_request, action: :diffs_batch, page: 1, per_page: 30) + end + private - def render_merge_request(merge_request, view: 'inline', **extra_params) - get :show, params: { + def render_merge_request(merge_request, action: :show, view: 'inline', **extra_params) + get action, params: { namespace_id: project.namespace.to_param, project_id: project, id: merge_request.to_param, diff --git a/spec/frontend/fixtures/static/mini_dropdown_graph.html b/spec/frontend/fixtures/static/mini_dropdown_graph.html deleted file mode 100644 index cde811d4f52..00000000000 --- a/spec/frontend/fixtures/static/mini_dropdown_graph.html +++ /dev/null @@ -1,13 +0,0 @@ -<div class="js-builds-dropdown-tests dropdown dropdown" data-testid="widget-mini-pipeline-graph"> - <button class="js-builds-dropdown-button" data-toggle="dropdown" data-stage-endpoint="foobar"> - Dropdown - </button> - <ul class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container"> - <li class="js-builds-dropdown-list scrollable-menu"> - <ul></ul> - </li> - <li class="js-builds-dropdown-loading hidden"> - <span class="gl-spinner"></span> - </li> - </ul> -</div> diff --git a/spec/frontend/fixtures/static/whats_new_notification.html b/spec/frontend/fixtures/static/whats_new_notification.html index 30d5eea91cc..3b4dbdf7d36 100644 --- a/spec/frontend/fixtures/static/whats_new_notification.html +++ b/spec/frontend/fixtures/static/whats_new_notification.html @@ -1,5 +1,5 @@ <div class='whats-new-notification-fixture-root'> - <div class='app' data-storage-key='storage-key'></div> + <div class='app' data-version-digest='version-digest'></div> <div class='header-help'> <div class='js-whats-new-notification-count'></div> </div> diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js index 228c897ab00..6d482e5814d 100644 --- a/spec/frontend/flash_spec.js +++ b/spec/frontend/flash_spec.js @@ -126,9 +126,17 @@ describe('Flash', () => { }); describe('deprecatedCreateFlash', () => { + const message = 'test'; + const type = 'alert'; + const parent = document; + const actionConfig = null; + const fadeTransition = false; + const addBodyClass = true; + const defaultParams = [message, type, parent, actionConfig, fadeTransition, addBodyClass]; + describe('no flash-container', () => { it('does not add to the DOM', () => { - const flashEl = deprecatedCreateFlash('testing'); + const flashEl = deprecatedCreateFlash(message); expect(flashEl).toBeNull(); @@ -138,11 +146,9 @@ describe('Flash', () => { describe('with flash-container', () => { beforeEach(() => { - document.body.innerHTML += ` - <div class="content-wrapper js-content-wrapper"> - <div class="flash-container"></div> - </div> - `; + setFixtures( + '<div class="content-wrapper js-content-wrapper"><div class="flash-container"></div></div>', + ); }); afterEach(() => { @@ -150,7 +156,7 @@ describe('Flash', () => { }); it('adds flash element into container', () => { - deprecatedCreateFlash('test', 'alert', document, null, false, true); + deprecatedCreateFlash(...defaultParams); expect(document.querySelector('.flash-alert')).not.toBeNull(); @@ -158,26 +164,35 @@ describe('Flash', () => { }); it('adds flash into specified parent', () => { - deprecatedCreateFlash('test', 'alert', document.querySelector('.content-wrapper')); + deprecatedCreateFlash( + message, + type, + document.querySelector('.content-wrapper'), + actionConfig, + fadeTransition, + addBodyClass, + ); expect(document.querySelector('.content-wrapper .flash-alert')).not.toBeNull(); + expect(document.querySelector('.content-wrapper').innerText.trim()).toEqual(message); }); it('adds container classes when inside content-wrapper', () => { - deprecatedCreateFlash('test'); + deprecatedCreateFlash(...defaultParams); expect(document.querySelector('.flash-text').className).toBe('flash-text'); + expect(document.querySelector('.content-wrapper').innerText.trim()).toEqual(message); }); it('does not add container when outside of content-wrapper', () => { document.querySelector('.content-wrapper').className = 'js-content-wrapper'; - deprecatedCreateFlash('test'); + deprecatedCreateFlash(...defaultParams); expect(document.querySelector('.flash-text').className.trim()).toContain('flash-text'); }); it('removes element after clicking', () => { - deprecatedCreateFlash('test', 'alert', document, null, false, true); + deprecatedCreateFlash(...defaultParams); document.querySelector('.flash-alert .js-close-icon').click(); @@ -188,24 +203,37 @@ describe('Flash', () => { describe('with actionConfig', () => { it('adds action link', () => { - deprecatedCreateFlash('test', 'alert', document, { - title: 'test', - }); + const newActionConfig = { title: 'test' }; + deprecatedCreateFlash( + message, + type, + parent, + newActionConfig, + fadeTransition, + addBodyClass, + ); expect(document.querySelector('.flash-action')).not.toBeNull(); }); it('calls actionConfig clickHandler on click', () => { - const actionConfig = { + const newActionConfig = { title: 'test', clickHandler: jest.fn(), }; - deprecatedCreateFlash('test', 'alert', document, actionConfig); + deprecatedCreateFlash( + message, + type, + parent, + newActionConfig, + fadeTransition, + addBodyClass, + ); document.querySelector('.flash-action').click(); - expect(actionConfig.clickHandler).toHaveBeenCalled(); + expect(newActionConfig.clickHandler).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 13dbda9cf55..5453c93eac3 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -665,6 +665,41 @@ describe('GfmAutoComplete', () => { expect(GfmAutoComplete.Members.nameOrUsernameIncludes(member, query)).toBe(result); }); }); + + describe('sorter', () => { + const query = 'c'; + + const items = [ + { search: 'DougHackett elayne.krieger' }, + { search: 'BerylHuel cherie.block' }, + { search: 'ErlindaMayert nicolle' }, + { search: 'Administrator root' }, + { search: 'PhoebeSchaden salina' }, + { search: 'CatherinTerry tommy.will' }, + { search: 'AntoineLedner ammie' }, + { search: 'KinaCummings robena' }, + { search: 'CharlsieHarber xzbdulia' }, + ]; + + const expected = [ + // Members whose name/username starts with `c` are grouped first + { search: 'BerylHuel cherie.block' }, + { search: 'CatherinTerry tommy.will' }, + { search: 'CharlsieHarber xzbdulia' }, + // Members whose name/username contains `c` are grouped second + { search: 'DougHackett elayne.krieger' }, + { search: 'ErlindaMayert nicolle' }, + { search: 'PhoebeSchaden salina' }, + { search: 'KinaCummings robena' }, + // Remaining members are grouped last + { search: 'Administrator root' }, + { search: 'AntoineLedner ammie' }, + ]; + + it('sorts by match with start of name/username, then match with any part of name/username, and maintains sort order', () => { + expect(GfmAutoComplete.Members.sort(query, items)).toMatchObject(expected); + }); + }); }); }); diff --git a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap index 2e02159a20c..1d2a5d636bc 100644 --- a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap +++ b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap @@ -9,7 +9,7 @@ exports[`grafana integration component default state to match the default snapsh class="settings-header" > <h4 - class="js-section-header" + class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only" > Grafana authentication diff --git a/spec/frontend/ide/components/cannot_push_code_alert_spec.js b/spec/frontend/ide/components/cannot_push_code_alert_spec.js new file mode 100644 index 00000000000..ff659ecdf3f --- /dev/null +++ b/spec/frontend/ide/components/cannot_push_code_alert_spec.js @@ -0,0 +1,72 @@ +import { GlButton, GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import CannotPushCodeAlert from '~/ide/components/cannot_push_code_alert.vue'; + +const TEST_MESSAGE = 'Hello test message!'; +const TEST_HREF = '/test/path/to/fork'; +const TEST_BUTTON_TEXT = 'Fork text'; + +describe('ide/components/cannot_push_code_alert', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + }); + + const createComponent = (props = {}) => { + wrapper = shallowMount(CannotPushCodeAlert, { + propsData: { + message: TEST_MESSAGE, + ...props, + }, + stubs: { + GlAlert: { + ...stubComponent(GlAlert), + template: `<div><slot></slot><slot name="actions"></slot></div>`, + }, + }, + }); + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findButtonData = () => { + const button = findAlert().findComponent(GlButton); + + if (!button.exists()) { + return null; + } + + return { + href: button.attributes('href'), + method: button.attributes('data-method'), + text: button.text(), + }; + }; + + describe('without actions', () => { + beforeEach(() => { + createComponent(); + }); + + it('shows alert with message', () => { + expect(findAlert().props()).toMatchObject({ dismissible: false }); + expect(findAlert().text()).toBe(TEST_MESSAGE); + }); + }); + + describe.each` + action | buttonData + ${{}} | ${null} + ${{ href: TEST_HREF, text: TEST_BUTTON_TEXT }} | ${{ href: TEST_HREF, text: TEST_BUTTON_TEXT }} + ${{ href: TEST_HREF, text: TEST_BUTTON_TEXT, isForm: true }} | ${{ href: TEST_HREF, text: TEST_BUTTON_TEXT, method: 'post' }} + `('with action=$action', ({ action, buttonData }) => { + beforeEach(() => { + createComponent({ action }); + }); + + it(`show button=${JSON.stringify(buttonData)}`, () => { + expect(findButtonData()).toEqual(buttonData); + }); + }); +}); diff --git a/spec/frontend/ide/components/commit_sidebar/form_spec.js b/spec/frontend/ide/components/commit_sidebar/form_spec.js index 083a2a73b24..f5916b021aa 100644 --- a/spec/frontend/ide/components/commit_sidebar/form_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/form_spec.js @@ -14,7 +14,7 @@ import { createBranchChangedCommitError, branchAlreadyExistsCommitError, } from '~/ide/lib/errors'; -import { MSG_CANNOT_PUSH_CODE_SHORT } from '~/ide/messages'; +import { MSG_CANNOT_PUSH_CODE } from '~/ide/messages'; import { createStore } from '~/ide/stores'; import { COMMIT_TO_NEW_BRANCH } from '~/ide/stores/modules/commit/constants'; @@ -85,8 +85,8 @@ describe('IDE commit form', () => { ${'when there are no changes'} | ${[]} | ${{ pushCode: true }} | ${goToEditView} | ${findBeginCommitButtonData} | ${true} | ${''} ${'when there are changes'} | ${['test']} | ${{ pushCode: true }} | ${goToEditView} | ${findBeginCommitButtonData} | ${false} | ${''} ${'when there are changes'} | ${['test']} | ${{ pushCode: true }} | ${goToCommitView} | ${findCommitButtonData} | ${false} | ${''} - ${'when user cannot push'} | ${['test']} | ${{ pushCode: false }} | ${goToEditView} | ${findBeginCommitButtonData} | ${true} | ${MSG_CANNOT_PUSH_CODE_SHORT} - ${'when user cannot push'} | ${['test']} | ${{ pushCode: false }} | ${goToCommitView} | ${findCommitButtonData} | ${true} | ${MSG_CANNOT_PUSH_CODE_SHORT} + ${'when user cannot push'} | ${['test']} | ${{ pushCode: false }} | ${goToEditView} | ${findBeginCommitButtonData} | ${true} | ${MSG_CANNOT_PUSH_CODE} + ${'when user cannot push'} | ${['test']} | ${{ pushCode: false }} | ${goToCommitView} | ${findCommitButtonData} | ${true} | ${MSG_CANNOT_PUSH_CODE} `('$desc', ({ stagedFiles, userPermissions, viewFn, buttonFn, disabled, tooltip }) => { beforeEach(async () => { store.state.stagedFiles = stagedFiles; diff --git a/spec/frontend/ide/components/ide_spec.js b/spec/frontend/ide/components/ide_spec.js index bd251f78654..b23a78a035d 100644 --- a/spec/frontend/ide/components/ide_spec.js +++ b/spec/frontend/ide/components/ide_spec.js @@ -1,10 +1,10 @@ -import { GlAlert } from '@gitlab/ui'; import { createLocalVue, shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; import waitForPromises from 'helpers/wait_for_promises'; +import CannotPushCodeAlert from '~/ide/components/cannot_push_code_alert.vue'; import ErrorMessage from '~/ide/components/error_message.vue'; import Ide from '~/ide/components/ide.vue'; -import { MSG_CANNOT_PUSH_CODE } from '~/ide/messages'; +import { MSG_CANNOT_PUSH_CODE_GO_TO_FORK, MSG_GO_TO_FORK } from '~/ide/messages'; import { createStore } from '~/ide/stores'; import { file } from '../helpers'; import { projectData } from '../mock_data'; @@ -12,14 +12,15 @@ import { projectData } from '../mock_data'; const localVue = createLocalVue(); localVue.use(Vuex); +const TEST_FORK_IDE_PATH = '/test/ide/path'; + describe('WebIDE', () => { const emptyProjData = { ...projectData, empty_repo: true, branches: {} }; + let store; let wrapper; const createComponent = ({ projData = emptyProjData, state = {} } = {}) => { - const store = createStore(); - store.state.currentProjectId = 'abcproject'; store.state.currentBranchId = 'master'; store.state.projects.abcproject = projData && { ...projData }; @@ -37,7 +38,11 @@ describe('WebIDE', () => { }); }; - const findAlert = () => wrapper.find(GlAlert); + const findAlert = () => wrapper.findComponent(CannotPushCodeAlert); + + beforeEach(() => { + store = createStore(); + }); afterEach(() => { wrapper.destroy(); @@ -148,6 +153,12 @@ describe('WebIDE', () => { }); it('when user cannot push code, shows alert', () => { + store.state.links = { + forkInfo: { + ide_path: TEST_FORK_IDE_PATH, + }, + }; + createComponent({ projData: { userPermissions: { @@ -157,9 +168,12 @@ describe('WebIDE', () => { }); expect(findAlert().props()).toMatchObject({ - dismissible: false, + message: MSG_CANNOT_PUSH_CODE_GO_TO_FORK, + action: { + href: TEST_FORK_IDE_PATH, + text: MSG_GO_TO_FORK, + }, }); - expect(findAlert().text()).toBe(MSG_CANNOT_PUSH_CODE); }); it.each` diff --git a/spec/frontend/ide/stores/getters_spec.js b/spec/frontend/ide/stores/getters_spec.js index 6b66c87e205..06456cdb12a 100644 --- a/spec/frontend/ide/stores/getters_spec.js +++ b/spec/frontend/ide/stores/getters_spec.js @@ -6,25 +6,40 @@ import { } from '~/ide/constants'; import { MSG_CANNOT_PUSH_CODE, - MSG_CANNOT_PUSH_CODE_SHORT, + MSG_CANNOT_PUSH_CODE_GO_TO_FORK, + MSG_CANNOT_PUSH_CODE_SHOULD_FORK, MSG_CANNOT_PUSH_UNSIGNED, MSG_CANNOT_PUSH_UNSIGNED_SHORT, + MSG_FORK, + MSG_GO_TO_FORK, } from '~/ide/messages'; import { createStore } from '~/ide/stores'; import * as getters from '~/ide/stores/getters'; import { file } from '../helpers'; const TEST_PROJECT_ID = 'test_project'; +const TEST_IDE_PATH = '/test/ide/path'; +const TEST_FORK_PATH = '/test/fork/path'; describe('IDE store getters', () => { let localState; let localStore; + let origGon; beforeEach(() => { + origGon = window.gon; + + // Feature flag is defaulted to on in prod + window.gon = { features: { rejectUnsignedCommitsByGitlab: true } }; + localStore = createStore(); localState = localStore.state; }); + afterEach(() => { + window.gon = origGon; + }); + describe('activeFile', () => { it('returns the current active file', () => { localState.openFiles.push(file()); @@ -433,27 +448,100 @@ describe('IDE store getters', () => { }); describe('canPushCodeStatus', () => { - it.each` - pushCode | rejectUnsignedCommits | expected - ${true} | ${false} | ${{ isAllowed: true, message: '', messageShort: '' }} - ${false} | ${false} | ${{ isAllowed: false, message: MSG_CANNOT_PUSH_CODE, messageShort: MSG_CANNOT_PUSH_CODE_SHORT }} - ${false} | ${true} | ${{ isAllowed: false, message: MSG_CANNOT_PUSH_UNSIGNED, messageShort: MSG_CANNOT_PUSH_UNSIGNED_SHORT }} - `( - 'with pushCode="$pushCode" and rejectUnsignedCommits="$rejectUnsignedCommits"', - ({ pushCode, rejectUnsignedCommits, expected }) => { - localState.projects[TEST_PROJECT_ID] = { - pushRules: { - [PUSH_RULE_REJECT_UNSIGNED_COMMITS]: rejectUnsignedCommits, + it.each([ + [ + 'when can push code, and can push unsigned commits', + { + input: { pushCode: true, rejectUnsignedCommits: false }, + output: { isAllowed: true, message: '', messageShort: '' }, + }, + ], + [ + 'when cannot push code, and can push unsigned commits', + { + input: { pushCode: false, rejectUnsignedCommits: false }, + output: { + isAllowed: false, + message: MSG_CANNOT_PUSH_CODE, + messageShort: MSG_CANNOT_PUSH_CODE, }, - userPermissions: { - [PERMISSION_PUSH_CODE]: pushCode, + }, + ], + [ + 'when cannot push code, and has ide_path in forkInfo', + { + input: { + pushCode: false, + rejectUnsignedCommits: false, + forkInfo: { ide_path: TEST_IDE_PATH }, }, - }; - localState.currentProjectId = TEST_PROJECT_ID; + output: { + isAllowed: false, + message: MSG_CANNOT_PUSH_CODE_GO_TO_FORK, + messageShort: MSG_CANNOT_PUSH_CODE, + action: { href: TEST_IDE_PATH, text: MSG_GO_TO_FORK }, + }, + }, + ], + [ + 'when cannot push code, and has fork_path in forkInfo', + { + input: { + pushCode: false, + rejectUnsignedCommits: false, + forkInfo: { fork_path: TEST_FORK_PATH }, + }, + output: { + isAllowed: false, + message: MSG_CANNOT_PUSH_CODE_SHOULD_FORK, + messageShort: MSG_CANNOT_PUSH_CODE, + action: { href: TEST_FORK_PATH, text: MSG_FORK, isForm: true }, + }, + }, + ], + [ + 'when can push code, but cannot push unsigned commits', + { + input: { pushCode: true, rejectUnsignedCommits: true }, + output: { + isAllowed: false, + message: MSG_CANNOT_PUSH_UNSIGNED, + messageShort: MSG_CANNOT_PUSH_UNSIGNED_SHORT, + }, + }, + ], + [ + 'when can push code, but cannot push unsigned commits, with reject_unsigned_commits_by_gitlab feature off', + { + input: { + pushCode: true, + rejectUnsignedCommits: true, + features: { rejectUnsignedCommitsByGitlab: false }, + }, + output: { + isAllowed: true, + message: '', + messageShort: '', + }, + }, + ], + ])('%s', (testName, { input, output }) => { + const { forkInfo, rejectUnsignedCommits, pushCode, features = {} } = input; - expect(localStore.getters.canPushCodeStatus).toEqual(expected); - }, - ); + Object.assign(window.gon.features, features); + localState.links = { forkInfo }; + localState.projects[TEST_PROJECT_ID] = { + pushRules: { + [PUSH_RULE_REJECT_UNSIGNED_COMMITS]: rejectUnsignedCommits, + }, + userPermissions: { + [PERMISSION_PUSH_CODE]: pushCode, + }, + }; + localState.currentProjectId = TEST_PROJECT_ID; + + expect(localStore.getters.canPushCodeStatus).toEqual(output); + }); }); describe('canPushCode', () => { diff --git a/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap index 4398d568501..07f90a12f0f 100644 --- a/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap +++ b/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap @@ -9,7 +9,9 @@ exports[`IncidentsSettingTabs should render the component 1`] = ` <div class="settings-header" > - <h4> + <h4 + class="settings-title js-settings-toggle js-settings-toggle-trigger-only" + > Incidents diff --git a/spec/frontend/integrations/edit/components/dynamic_field_spec.js b/spec/frontend/integrations/edit/components/dynamic_field_spec.js index aaca9fc4e62..2ebb3333c0f 100644 --- a/spec/frontend/integrations/edit/components/dynamic_field_spec.js +++ b/spec/frontend/integrations/edit/components/dynamic_field_spec.js @@ -192,17 +192,6 @@ describe('DynamicField', () => { expect(findGlFormGroup().find('label').text()).toBe(defaultProps.title); }); - - describe('for password field with some value (hidden by backend)', () => { - it('renders label with new password title', () => { - createComponent({ - type: 'password', - value: 'true', - }); - - expect(findGlFormGroup().find('label').text()).toBe(`Enter new ${defaultProps.title}`); - }); - }); }); describe('validations', () => { diff --git a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js index 3938e7c7c22..d08a1904e06 100644 --- a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js +++ b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js @@ -1,7 +1,7 @@ import { GlFormCheckbox, GlFormInput } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; - import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue'; +import JiraUpgradeCta from '~/integrations/edit/components/jira_upgrade_cta.vue'; import eventHub from '~/integrations/edit/event_hub'; describe('JiraIssuesFields', () => { @@ -28,23 +28,46 @@ describe('JiraIssuesFields', () => { } }); - const findEnableCheckbox = () => wrapper.find(GlFormCheckbox); - const findProjectKey = () => wrapper.find(GlFormInput); - const expectedBannerText = 'This is a Premium feature'; + const findEnableCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const findProjectKey = () => wrapper.findComponent(GlFormInput); + const findJiraUpgradeCta = () => wrapper.findComponent(JiraUpgradeCta); const findJiraForVulnerabilities = () => wrapper.find('[data-testid="jira-for-vulnerabilities"]'); const setEnableCheckbox = async (isEnabled = true) => findEnableCheckbox().vm.$emit('input', isEnabled); + describe('jira issues call to action', () => { + it('shows the premium message', () => { + createComponent({ + props: { showJiraIssuesIntegration: false }, + }); + + expect(findJiraUpgradeCta().props()).toMatchObject({ + showPremiumMessage: true, + showUltimateMessage: false, + }); + }); + + it('shows the ultimate message', () => { + createComponent({ + props: { + showJiraIssuesIntegration: true, + showJiraVulnerabilitiesIntegration: false, + }, + }); + + expect(findJiraUpgradeCta().props()).toMatchObject({ + showPremiumMessage: false, + showUltimateMessage: true, + }); + }); + }); + describe('template', () => { describe('upgrade banner for non-Premium user', () => { beforeEach(() => { createComponent({ props: { initialProjectKey: '', showJiraIssuesIntegration: false } }); }); - it('shows upgrade banner', () => { - expect(wrapper.text()).toContain(expectedBannerText); - }); - it('does not show checkbox and input field', () => { expect(findEnableCheckbox().exists()).toBe(false); expect(findProjectKey().exists()).toBe(false); @@ -57,7 +80,7 @@ describe('JiraIssuesFields', () => { }); it('does not show upgrade banner', () => { - expect(wrapper.text()).not.toContain(expectedBannerText); + expect(findJiraUpgradeCta().exists()).toBe(false); }); // As per https://vuejs.org/v2/guide/forms.html#Checkbox-1, @@ -125,6 +148,14 @@ describe('JiraIssuesFields', () => { }, ); + it('passes down the correct show-full-feature property', async () => { + await setEnableCheckbox(true); + expect(findJiraForVulnerabilities().attributes('show-full-feature')).toBe('true'); + wrapper.setProps({ showJiraVulnerabilitiesIntegration: false }); + await wrapper.vm.$nextTick(); + expect(findJiraForVulnerabilities().attributes('show-full-feature')).toBeUndefined(); + }); + it('passes down the correct initial-issue-type-id value when value is empty', async () => { await setEnableCheckbox(true); expect(findJiraForVulnerabilities().attributes('initial-issue-type-id')).toBeUndefined(); diff --git a/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js index c6e7ee44355..5c04add61a1 100644 --- a/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js +++ b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js @@ -30,14 +30,23 @@ describe('JiraTriggerFields', () => { const findCommentSettings = () => wrapper.find('[data-testid="comment-settings"]'); const findCommentDetail = () => wrapper.find('[data-testid="comment-detail"]'); const findCommentSettingsCheckbox = () => findCommentSettings().find(GlFormCheckbox); + const findIssueTransitionEnabled = () => + wrapper.find('[data-testid="issue-transition-enabled"] input[type="checkbox"]'); + const findIssueTransitionMode = () => wrapper.find('[data-testid="issue-transition-mode"]'); + const findIssueTransitionModeRadios = () => + findIssueTransitionMode().findAll('input[type="radio"]'); + const findIssueTransitionIdsField = () => + wrapper.find('input[type="text"][name="service[jira_issue_transition_id]"]'); describe('template', () => { describe('initialTriggerCommit and initialTriggerMergeRequest are false', () => { - it('does not show comment settings', () => { + it('does not show trigger settings', () => { createComponent(); expect(findCommentSettings().isVisible()).toBe(false); expect(findCommentDetail().isVisible()).toBe(false); + expect(findIssueTransitionEnabled().exists()).toBe(false); + expect(findIssueTransitionMode().exists()).toBe(false); }); }); @@ -48,9 +57,11 @@ describe('JiraTriggerFields', () => { }); }); - it('shows comment settings', () => { + it('shows trigger settings', () => { expect(findCommentSettings().isVisible()).toBe(true); expect(findCommentDetail().isVisible()).toBe(false); + expect(findIssueTransitionEnabled().isVisible()).toBe(true); + expect(findIssueTransitionMode().exists()).toBe(false); }); // As per https://vuejs.org/v2/guide/forms.html#Checkbox-1, @@ -73,13 +84,15 @@ describe('JiraTriggerFields', () => { }); describe('initialTriggerMergeRequest is true', () => { - it('shows comment settings', () => { + it('shows trigger settings', () => { createComponent({ initialTriggerMergeRequest: true, }); expect(findCommentSettings().isVisible()).toBe(true); expect(findCommentDetail().isVisible()).toBe(false); + expect(findIssueTransitionEnabled().isVisible()).toBe(true); + expect(findIssueTransitionMode().exists()).toBe(false); }); }); @@ -95,21 +108,94 @@ describe('JiraTriggerFields', () => { }); }); - it('disables checkboxes and radios if inheriting', () => { + describe('initialJiraIssueTransitionAutomatic is false, initialJiraIssueTransitionId is not set', () => { + it('selects automatic transitions when enabling transitions', () => { + createComponent({ + initialTriggerCommit: true, + initialEnableComments: true, + }); + + const checkbox = findIssueTransitionEnabled(); + expect(checkbox.element.checked).toBe(false); + checkbox.trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + const [radio1, radio2] = findIssueTransitionModeRadios().wrappers; + expect(radio1.element.checked).toBe(true); + expect(radio2.element.checked).toBe(false); + }); + }); + }); + + describe('initialJiraIssueTransitionAutomatic is true', () => { + it('uses automatic transitions', () => { + createComponent({ + initialTriggerCommit: true, + initialJiraIssueTransitionAutomatic: true, + }); + + expect(findIssueTransitionEnabled().element.checked).toBe(true); + + const [radio1, radio2] = findIssueTransitionModeRadios().wrappers; + expect(radio1.element.checked).toBe(true); + expect(radio2.element.checked).toBe(false); + + expect(findIssueTransitionIdsField().exists()).toBe(false); + }); + }); + + describe('initialJiraIssueTransitionId is set', () => { + it('uses custom transitions', () => { + createComponent({ + initialTriggerCommit: true, + initialJiraIssueTransitionId: '1, 2, 3', + }); + + expect(findIssueTransitionEnabled().element.checked).toBe(true); + + const [radio1, radio2] = findIssueTransitionModeRadios().wrappers; + expect(radio1.element.checked).toBe(false); + expect(radio2.element.checked).toBe(true); + + const field = findIssueTransitionIdsField(); + expect(field.isVisible()).toBe(true); + expect(field.element).toMatchObject({ + type: 'text', + value: '1, 2, 3', + }); + }); + }); + + describe('initialJiraIssueTransitionAutomatic is true, initialJiraIssueTransitionId is set', () => { + it('uses automatic transitions', () => { + createComponent({ + initialTriggerCommit: true, + initialJiraIssueTransitionAutomatic: true, + initialJiraIssueTransitionId: '1, 2, 3', + }); + + expect(findIssueTransitionEnabled().element.checked).toBe(true); + + const [radio1, radio2] = findIssueTransitionModeRadios().wrappers; + expect(radio1.element.checked).toBe(true); + expect(radio2.element.checked).toBe(false); + + expect(findIssueTransitionIdsField().exists()).toBe(false); + }); + }); + + it('disables input fields if inheriting', () => { createComponent( { initialTriggerCommit: true, initialEnableComments: true, + initialJiraIssueTransitionId: '1, 2, 3', }, true, ); - wrapper.findAll('[type=checkbox]').wrappers.forEach((checkbox) => { - expect(checkbox.attributes('disabled')).toBe('disabled'); - }); - - wrapper.findAll('[type=radio]').wrappers.forEach((radio) => { - expect(radio.attributes('disabled')).toBe('disabled'); + wrapper.findAll('[type=text], [type=checkbox], [type=radio]').wrappers.forEach((input) => { + expect(input.attributes('disabled')).toBe('disabled'); }); }); }); diff --git a/spec/frontend/integrations/edit/components/jira_upgrade_cta_spec.js b/spec/frontend/integrations/edit/components/jira_upgrade_cta_spec.js new file mode 100644 index 00000000000..e49a1619627 --- /dev/null +++ b/spec/frontend/integrations/edit/components/jira_upgrade_cta_spec.js @@ -0,0 +1,30 @@ +import { shallowMount } from '@vue/test-utils'; +import JiraUpgradeCta from '~/integrations/edit/components/jira_upgrade_cta.vue'; + +describe('JiraUpgradeCta', () => { + let wrapper; + + const contentMessage = 'Upgrade your plan to enable this feature of the Jira Integration.'; + + const createComponent = (propsData) => { + wrapper = shallowMount(JiraUpgradeCta, { + propsData, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays the correct message for premium and lower users', () => { + createComponent({ showPremiumMessage: true }); + expect(wrapper.html()).toContain('This is a Premium feature'); + expect(wrapper.html()).toContain(contentMessage); + }); + + it('displays the correct message for ultimate and lower users', () => { + createComponent({ showUltimateMessage: true }); + expect(wrapper.html()).toContain('This is an Ultimate feature'); + expect(wrapper.html()).toContain(contentMessage); + }); +}); diff --git a/spec/frontend/integrations/edit/components/trigger_fields_spec.js b/spec/frontend/integrations/edit/components/trigger_fields_spec.js index 3e5326812b1..b9d16464e72 100644 --- a/spec/frontend/integrations/edit/components/trigger_fields_spec.js +++ b/spec/frontend/integrations/edit/components/trigger_fields_spec.js @@ -138,11 +138,11 @@ describe('TriggerFields', () => { const expectedResults = [ { name: 'service[push_channel]', - placeholder: 'Slack channels (e.g. general, development)', + placeholder: 'general, development', }, { name: 'service[merge_request_channel]', - placeholder: 'Slack channels (e.g. general, development)', + placeholder: 'general, development', }, ]; diff --git a/spec/frontend/integrations/index/components/integrations_list_spec.js b/spec/frontend/integrations/index/components/integrations_list_spec.js new file mode 100644 index 00000000000..94fd7fc84ee --- /dev/null +++ b/spec/frontend/integrations/index/components/integrations_list_spec.js @@ -0,0 +1,26 @@ +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import IntegrationsList from '~/integrations/index/components/integrations_list.vue'; +import { mockActiveIntegrations, mockInactiveIntegrations } from '../mock_data'; + +describe('IntegrationsList', () => { + let wrapper; + + const findActiveIntegrationsTable = () => wrapper.findByTestId('active-integrations-table'); + const findInactiveIntegrationsTable = () => wrapper.findByTestId('inactive-integrations-table'); + + const createComponent = (propsData = {}) => { + wrapper = extendedWrapper(shallowMount(IntegrationsList, { propsData })); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('provides correct `integrations` prop to the IntegrationsTable instance', () => { + createComponent({ integrations: [...mockInactiveIntegrations, ...mockActiveIntegrations] }); + + expect(findActiveIntegrationsTable().props('integrations')).toEqual(mockActiveIntegrations); + expect(findInactiveIntegrationsTable().props('integrations')).toEqual(mockInactiveIntegrations); + }); +}); diff --git a/spec/frontend/integrations/index/components/integrations_table_spec.js b/spec/frontend/integrations/index/components/integrations_table_spec.js new file mode 100644 index 00000000000..bfe0a5987b4 --- /dev/null +++ b/spec/frontend/integrations/index/components/integrations_table_spec.js @@ -0,0 +1,53 @@ +import { GlTable, GlIcon } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import IntegrationsTable from '~/integrations/index/components/integrations_table.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +import { mockActiveIntegrations, mockInactiveIntegrations } from '../mock_data'; + +describe('IntegrationsTable', () => { + let wrapper; + + const findTable = () => wrapper.findComponent(GlTable); + + const createComponent = (propsData = {}) => { + wrapper = mount(IntegrationsTable, { + propsData: { + integrations: mockActiveIntegrations, + ...propsData, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each([true, false])('when `showUpdatedAt` is %p', (showUpdatedAt) => { + beforeEach(() => { + createComponent({ showUpdatedAt }); + }); + + it(`${showUpdatedAt ? 'renders' : 'does not render'} content in "Last updated" column`, () => { + const headers = findTable().findAll('th'); + expect(headers.wrappers.some((header) => header.text() === 'Last updated')).toBe( + showUpdatedAt, + ); + expect(wrapper.findComponent(TimeAgoTooltip).exists()).toBe(showUpdatedAt); + }); + }); + + describe.each` + scenario | integrations | shouldRenderActiveIcon + ${'when integration is active'} | ${[mockActiveIntegrations[0]]} | ${true} + ${'when integration is inactive'} | ${[mockInactiveIntegrations[0]]} | ${false} + `('$scenario', ({ shouldRenderActiveIcon, integrations }) => { + beforeEach(() => { + createComponent({ integrations }); + }); + + it(`${shouldRenderActiveIcon ? 'renders' : 'does not render'} icon in first column`, () => { + expect(findTable().findComponent(GlIcon).exists()).toBe(shouldRenderActiveIcon); + }); + }); +}); diff --git a/spec/frontend/integrations/index/mock_data.js b/spec/frontend/integrations/index/mock_data.js new file mode 100644 index 00000000000..2231687d255 --- /dev/null +++ b/spec/frontend/integrations/index/mock_data.js @@ -0,0 +1,50 @@ +export const mockActiveIntegrations = [ + { + active: true, + title: 'Asana', + description: 'Asana - Teamwork without email', + updated_at: '2021-03-18T00:27:09.634Z', + edit_path: + '/gitlab-qa-sandbox-group/project_with_jenkins_6a55a67c-57c6ed0597c9319a/-/services/asana/edit', + name: 'asana', + }, + { + active: true, + title: 'Jira', + description: 'Jira issue tracker', + updated_at: '2021-01-29T06:41:25.806Z', + edit_path: + '/gitlab-qa-sandbox-group/project_with_jenkins_6a55a67c-57c6ed0597c9319a/-/services/jira/edit', + name: 'jira', + }, +]; + +export const mockInactiveIntegrations = [ + { + active: false, + title: 'Webex Teams', + description: 'Receive event notifications in Webex Teams', + updated_at: null, + edit_path: + '/gitlab-qa-sandbox-group/project_with_jenkins_6a55a67c-57c6ed0597c9319a/-/services/webex_teams/edit', + name: 'webex_teams', + }, + { + active: false, + title: 'YouTrack', + description: 'YouTrack issue tracker', + updated_at: null, + edit_path: + '/gitlab-qa-sandbox-group/project_with_jenkins_6a55a67c-57c6ed0597c9319a/-/services/youtrack/edit', + name: 'youtrack', + }, + { + active: false, + title: 'Atlassian Bamboo CI', + description: 'A continuous integration and build server', + updated_at: null, + edit_path: + '/gitlab-qa-sandbox-group/project_with_jenkins_6a55a67c-57c6ed0597c9319a/-/services/bamboo/edit', + name: 'bamboo', + }, +]; diff --git a/spec/frontend/invite_member/components/invite_member_modal_spec.js b/spec/frontend/invite_member/components/invite_member_modal_spec.js index 4eff19402a8..03e3da2d5ef 100644 --- a/spec/frontend/invite_member/components/invite_member_modal_spec.js +++ b/spec/frontend/invite_member/components/invite_member_modal_spec.js @@ -9,7 +9,7 @@ const memberPath = 'member_path'; const GlEmoji = { template: '<img />' }; const createComponent = () => { return shallowMount(InviteMemberModal, { - provide: { + propsData: { membersPath: memberPath, }, stubs: { diff --git a/spec/frontend/invite_member/components/invite_member_trigger_spec.js b/spec/frontend/invite_member/components/invite_member_trigger_spec.js index 67c312fd155..630e2dbfc16 100644 --- a/spec/frontend/invite_member/components/invite_member_trigger_spec.js +++ b/spec/frontend/invite_member/components/invite_member_trigger_spec.js @@ -5,7 +5,7 @@ import InviteMemberTrigger from '~/invite_member/components/invite_member_trigge import triggerProvides from './invite_member_trigger_mock_data'; const createComponent = () => { - return shallowMount(InviteMemberTrigger, { provide: triggerProvides }); + return shallowMount(InviteMemberTrigger, { propsData: triggerProvides }); }; describe('InviteMemberTrigger', () => { diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js index 5ca5d855038..7ed18775693 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -3,7 +3,11 @@ import { shallowMount } from '@vue/test-utils'; import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; import Api from '~/api'; +import ExperimentTracking from '~/experimentation/experiment_tracking'; import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue'; +import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants'; + +jest.mock('~/experimentation/experiment_tracking'); const id = '1'; const name = 'test name'; @@ -89,7 +93,7 @@ describe('InviteMembersModal', () => { }); it('renders the modal with the correct title', () => { - expect(wrapper.findComponent(GlModal).props('title')).toBe('Invite team members'); + expect(wrapper.findComponent(GlModal).props('title')).toBe('Invite members'); }); it('renders the Cancel button text correctly', () => { @@ -303,6 +307,7 @@ describe('InviteMembersModal', () => { jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData }); jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); jest.spyOn(wrapper.vm, 'showToastMessageSuccess'); + jest.spyOn(wrapper.vm, 'trackInvite'); clickInviteButton(); }); @@ -396,5 +401,46 @@ describe('InviteMembersModal', () => { }); }); }); + + describe('tracking', () => { + const postData = { + user_id: '1', + access_level: defaultAccessLevel, + expires_at: undefined, + format: 'json', + }; + + beforeEach(() => { + wrapper = createComponent({ newUsersToInvite: [user3] }); + + wrapper.vm.$toast = { show: jest.fn() }; + jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData }); + }); + + it('tracks the invite', () => { + wrapper.vm.openModal({ inviteeType: 'members', source: INVITE_MEMBERS_IN_COMMENT }); + + clickInviteButton(); + + expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_IN_COMMENT); + expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('comment_invite_success'); + }); + + it('does not track invite for unknown source', () => { + wrapper.vm.openModal({ inviteeType: 'members', source: 'unknown' }); + + clickInviteButton(); + + expect(ExperimentTracking).not.toHaveBeenCalled(); + }); + + it('does not track invite undefined source', () => { + wrapper.vm.openModal({ inviteeType: 'members' }); + + clickInviteButton(); + + expect(ExperimentTracking).not.toHaveBeenCalled(); + }); + }); }); }); 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 f362aace1df..b569b6286e0 100644 --- a/spec/frontend/invite_members/components/invite_members_trigger_spec.js +++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js @@ -1,35 +1,99 @@ -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import ExperimentTracking from '~/experimentation/experiment_tracking'; import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; +import eventHub from '~/invite_members/event_hub'; + +jest.mock('~/experimentation/experiment_tracking'); const displayText = 'Invite team members'; +let wrapper; +let triggerProps; +let findButton; +const triggerComponent = { + button: GlButton, + anchor: GlLink, +}; const createComponent = (props = {}) => { - return shallowMount(InviteMembersTrigger, { + wrapper = shallowMount(InviteMembersTrigger, { propsData: { displayText, + ...triggerProps, ...props, }, }); }; -describe('InviteMembersTrigger', () => { - let wrapper; +describe.each(['button', 'anchor'])('with triggerElement as %s', (triggerElement) => { + triggerProps = { triggerElement }; + findButton = () => wrapper.findComponent(triggerComponent[triggerElement]); afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('displayText', () => { - const findButton = () => wrapper.findComponent(GlButton); + it('includes the correct displayText for the button', () => { + createComponent(); + + expect(findButton().text()).toBe(displayText); + }); + }); + + describe('clicking the link', () => { + let spy; beforeEach(() => { - wrapper = createComponent(); + spy = jest.spyOn(eventHub, '$emit'); }); - it('includes the correct displayText for the button', () => { - expect(findButton().text()).toBe(displayText); + it('emits openModal from an unknown source', () => { + createComponent(); + + findButton().vm.$emit('click'); + + expect(spy).toHaveBeenCalledWith('openModal', { inviteeType: 'members', source: 'unknown' }); + }); + + it('emits openModal from a named source', () => { + createComponent({ triggerSource: '_trigger_source_' }); + + findButton().vm.$emit('click'); + + expect(spy).toHaveBeenCalledWith('openModal', { + inviteeType: 'members', + source: '_trigger_source_', + }); + }); + }); + + describe('tracking', () => { + it('tracks on mounting', () => { + createComponent({ trackExperiment: '_track_experiment_' }); + + expect(ExperimentTracking).toHaveBeenCalledWith('_track_experiment_'); + expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('comment_invite_shown'); + }); + + it('does not track on mounting', () => { + createComponent(); + + expect(ExperimentTracking).not.toHaveBeenCalledWith('_track_experiment_'); + }); + + it('does not add tracking attributes', () => { + createComponent(); + + expect(findButton().attributes('data-track-event')).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-label')).toBe('_label_'); }); }); }); diff --git a/spec/frontend/issuable/components/csv_export_modal_spec.js b/spec/frontend/issuable/components/csv_export_modal_spec.js index f46b6f72f05..a327da2d63a 100644 --- a/spec/frontend/issuable/components/csv_export_modal_spec.js +++ b/spec/frontend/issuable/components/csv_export_modal_spec.js @@ -58,14 +58,14 @@ describe('CsvExportModal', () => { describe('issuable count info text', () => { it('displays the info text when issuableCount is > -1', () => { - wrapper = createComponent({ injectedProperties: { issuableCount: 10 } }); + wrapper = createComponent({ props: { issuableCount: 10 } }); expect(wrapper.findByTestId('issuable-count-note').exists()).toBe(true); expect(wrapper.findByTestId('issuable-count-note').text()).toContain('10 issues selected'); expect(findIcon().exists()).toBe(true); }); it("doesn't display the info text when issuableCount is -1", () => { - wrapper = createComponent({ injectedProperties: { issuableCount: -1 } }); + wrapper = createComponent({ props: { issuableCount: -1 } }); expect(wrapper.findByTestId('issuable-count-note').exists()).toBe(false); }); }); @@ -83,7 +83,7 @@ describe('CsvExportModal', () => { describe('primary button', () => { it('passes the exportCsvPath to the button', () => { const exportCsvPath = '/gitlab-org/gitlab-test/-/issues/export_csv'; - wrapper = createComponent({ injectedProperties: { exportCsvPath } }); + wrapper = createComponent({ props: { exportCsvPath } }); expect(findButton().attributes('href')).toBe(exportCsvPath); }); }); diff --git a/spec/frontend/issuable/components/csv_import_export_buttons_spec.js b/spec/frontend/issuable/components/csv_import_export_buttons_spec.js index e32bf35b13a..2fe8d28a333 100644 --- a/spec/frontend/issuable/components/csv_import_export_buttons_spec.js +++ b/spec/frontend/issuable/components/csv_import_export_buttons_spec.js @@ -9,6 +9,9 @@ describe('CsvImportExportButtons', () => { let wrapper; let glModalDirective; + const exportCsvPath = '/gitlab-org/gitlab-test/-/issues/export_csv'; + const issuableCount = 10; + function createComponent(injectedProperties = {}) { glModalDirective = jest.fn(); return extendedWrapper( @@ -24,6 +27,10 @@ describe('CsvImportExportButtons', () => { provide: { ...injectedProperties, }, + propsData: { + exportCsvPath, + issuableCount, + }, }), ); } @@ -57,7 +64,7 @@ describe('CsvImportExportButtons', () => { }); it('renders the export modal', () => { - expect(findExportCsvModal().exists()).toBe(true); + expect(findExportCsvModal().props()).toMatchObject({ exportCsvPath, issuableCount }); }); it('opens the export modal', () => { diff --git a/spec/frontend/issuable_list/components/issuable_list_root_spec.js b/spec/frontend/issuable_list/components/issuable_list_root_spec.js index 9c57233548c..38d6d6d86bc 100644 --- a/spec/frontend/issuable_list/components/issuable_list_root_spec.js +++ b/spec/frontend/issuable_list/components/issuable_list_root_spec.js @@ -1,5 +1,6 @@ import { GlSkeletonLoading, GlPagination } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import VueDraggable from 'vuedraggable'; import { TEST_HOST } from 'helpers/test_constants'; @@ -11,7 +12,7 @@ import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filte import { mockIssuableListProps, mockIssuables } from '../mock_data'; const createComponent = ({ props = mockIssuableListProps, data = {} } = {}) => - mount(IssuableListRoot, { + shallowMount(IssuableListRoot, { propsData: props, data() { return data; @@ -24,20 +25,29 @@ const createComponent = ({ props = mockIssuableListProps, data = {} } = {}) => <p class="js-issuable-empty-state">Issuable empty state</p> `, }, + stubs: { + IssuableTabs, + }, }); describe('IssuableListRoot', () => { let wrapper; - beforeEach(() => { - wrapper = createComponent(); - }); + const findFilteredSearchBar = () => wrapper.findComponent(FilteredSearchBar); + const findGlPagination = () => wrapper.findComponent(GlPagination); + const findIssuableItem = () => wrapper.findComponent(IssuableItem); + const findIssuableTabs = () => wrapper.findComponent(IssuableTabs); + const findVueDraggable = () => wrapper.findComponent(VueDraggable); afterEach(() => { wrapper.destroy(); }); describe('computed', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + const mockCheckedIssuables = { [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] }, [mockIssuables[1].iid]: { checked: true, issuable: mockIssuables[1] }, @@ -108,6 +118,10 @@ describe('IssuableListRoot', () => { }); describe('watch', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + describe('issuables', () => { it('populates `checkedIssuables` prop with all issuables', async () => { wrapper.setProps({ @@ -147,6 +161,10 @@ describe('IssuableListRoot', () => { }); describe('methods', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + describe('issuableId', () => { it('returns id value from provided issuable object', () => { expect(wrapper.vm.issuableId({ id: 1 })).toBe(1); @@ -171,12 +189,16 @@ describe('IssuableListRoot', () => { }); describe('template', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + it('renders component container element with class "issuable-list-container"', () => { expect(wrapper.classes()).toContain('issuable-list-container'); }); it('renders issuable-tabs component', () => { - const tabsEl = wrapper.find(IssuableTabs); + const tabsEl = findIssuableTabs(); expect(tabsEl.exists()).toBe(true); expect(tabsEl.props()).toMatchObject({ @@ -187,14 +209,14 @@ describe('IssuableListRoot', () => { }); it('renders contents for slot "nav-actions" within issuable-tab component', () => { - const buttonEl = wrapper.find(IssuableTabs).find('button.js-new-issuable'); + const buttonEl = findIssuableTabs().find('button.js-new-issuable'); expect(buttonEl.exists()).toBe(true); expect(buttonEl.text()).toBe('New issuable'); }); it('renders filtered-search-bar component', () => { - const searchEl = wrapper.find(FilteredSearchBar); + const searchEl = findFilteredSearchBar(); const { namespace, recentSearchesStorageKey, @@ -224,11 +246,13 @@ describe('IssuableListRoot', () => { await wrapper.vm.$nextTick(); - expect(wrapper.findAll(GlSkeletonLoading)).toHaveLength(wrapper.vm.skeletonItemCount); + expect(wrapper.findAllComponents(GlSkeletonLoading)).toHaveLength( + wrapper.vm.skeletonItemCount, + ); }); it('renders issuable-item component for each item within `issuables` array', () => { - const itemsEl = wrapper.findAll(IssuableItem); + const itemsEl = wrapper.findAllComponents(IssuableItem); const mockIssuable = mockIssuableListProps.issuables[0]; expect(itemsEl).toHaveLength(mockIssuableListProps.issuables.length); @@ -257,7 +281,7 @@ describe('IssuableListRoot', () => { await wrapper.vm.$nextTick(); - const paginationEl = wrapper.find(GlPagination); + const paginationEl = findGlPagination(); expect(paginationEl.exists()).toBe(true); expect(paginationEl.props()).toMatchObject({ perPage: 20, @@ -271,10 +295,8 @@ describe('IssuableListRoot', () => { }); describe('events', () => { - let wrapperChecked; - beforeEach(() => { - wrapperChecked = createComponent({ + wrapper = createComponent({ data: { checkedIssuables: { [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] }, @@ -283,34 +305,30 @@ describe('IssuableListRoot', () => { }); }); - afterEach(() => { - wrapperChecked.destroy(); - }); - it('issuable-tabs component emits `click-tab` event on `click-tab` event', () => { - wrapper.find(IssuableTabs).vm.$emit('click'); + findIssuableTabs().vm.$emit('click'); expect(wrapper.emitted('click-tab')).toBeTruthy(); }); it('sets all issuables as checked when filtered-search-bar component emits `checked-input` event', async () => { - const searchEl = wrapperChecked.find(FilteredSearchBar); + const searchEl = findFilteredSearchBar(); searchEl.vm.$emit('checked-input', true); - await wrapperChecked.vm.$nextTick(); + await wrapper.vm.$nextTick(); expect(searchEl.emitted('checked-input')).toBeTruthy(); expect(searchEl.emitted('checked-input').length).toBe(1); - expect(wrapperChecked.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({ + expect(wrapper.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({ checked: true, issuable: mockIssuables[0], }); }); it('filtered-search-bar component emits `filter` event on `onFilter` & `sort` event on `onSort` events', () => { - const searchEl = wrapper.find(FilteredSearchBar); + const searchEl = findFilteredSearchBar(); searchEl.vm.$emit('onFilter'); expect(wrapper.emitted('filter')).toBeTruthy(); @@ -319,21 +337,33 @@ describe('IssuableListRoot', () => { }); it('sets an issuable as checked when issuable-item component emits `checked-input` event', async () => { - const issuableItem = wrapperChecked.findAll(IssuableItem).at(0); + const issuableItem = wrapper.findAllComponents(IssuableItem).at(0); issuableItem.vm.$emit('checked-input', true); - await wrapperChecked.vm.$nextTick(); + await wrapper.vm.$nextTick(); expect(issuableItem.emitted('checked-input')).toBeTruthy(); expect(issuableItem.emitted('checked-input').length).toBe(1); - expect(wrapperChecked.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({ + expect(wrapper.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({ checked: true, issuable: mockIssuables[0], }); }); + it('emits `update-legacy-bulk-edit` when filtered-search-bar checkbox is checked', () => { + findFilteredSearchBar().vm.$emit('checked-input'); + + expect(wrapper.emitted('update-legacy-bulk-edit')).toEqual([[]]); + }); + + it('emits `update-legacy-bulk-edit` when issuable-item checkbox is checked', () => { + findIssuableItem().vm.$emit('checked-input'); + + expect(wrapper.emitted('update-legacy-bulk-edit')).toEqual([[]]); + }); + it('gl-pagination component emits `page-change` event on `input` event', async () => { wrapper.setProps({ showPaginationControls: true, @@ -341,8 +371,48 @@ describe('IssuableListRoot', () => { await wrapper.vm.$nextTick(); - wrapper.find(GlPagination).vm.$emit('input'); + findGlPagination().vm.$emit('input'); expect(wrapper.emitted('page-change')).toBeTruthy(); }); }); + + describe('manual sorting', () => { + describe('when enabled', () => { + beforeEach(() => { + wrapper = createComponent({ + props: { + ...mockIssuableListProps, + isManualOrdering: true, + }, + }); + }); + + it('renders VueDraggable component', () => { + expect(findVueDraggable().exists()).toBe(true); + }); + + it('IssuableItem has grab cursor', () => { + expect(findIssuableItem().classes()).toContain('gl-cursor-grab'); + }); + + it('emits a "reorder" event when user updates the issue order', () => { + const oldIndex = 4; + const newIndex = 6; + + findVueDraggable().vm.$emit('update', { oldIndex, newIndex }); + + expect(wrapper.emitted('reorder')).toEqual([[{ oldIndex, newIndex }]]); + }); + }); + + describe('when disabled', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('does not render VueDraggable component', () => { + expect(findVueDraggable().exists()).toBe(false); + }); + }); + }); }); diff --git a/spec/frontend/issuable_list/components/issuable_tabs_spec.js b/spec/frontend/issuable_list/components/issuable_tabs_spec.js index 3cc237b9ce9..cbf5765078a 100644 --- a/spec/frontend/issuable_list/components/issuable_tabs_spec.js +++ b/spec/frontend/issuable_list/components/issuable_tabs_spec.js @@ -34,6 +34,9 @@ describe('IssuableTabs', () => { wrapper.destroy(); }); + const findAllGlBadges = () => wrapper.findAllComponents(GlBadge); + const findAllGlTabs = () => wrapper.findAllComponents(GlTab); + describe('methods', () => { describe('isTabActive', () => { it.each` @@ -57,17 +60,19 @@ describe('IssuableTabs', () => { describe('template', () => { it('renders gl-tab for each tab within `tabs` array', () => { - const tabsEl = wrapper.findAll(GlTab); + const tabsEl = findAllGlTabs(); expect(tabsEl.exists()).toBe(true); expect(tabsEl).toHaveLength(mockIssuableListProps.tabs.length); }); it('renders gl-badge component within a tab', () => { - const badgeEl = wrapper.findAll(GlBadge).at(0); + const badges = findAllGlBadges(); - expect(badgeEl.exists()).toBe(true); - expect(badgeEl.text()).toBe(`${mockIssuableListProps.tabCounts.opened}`); + // Does not render `All` badge since it has an undefined count + expect(badges).toHaveLength(2); + expect(badges.at(0).text()).toBe(`${mockIssuableListProps.tabCounts.opened}`); + expect(badges.at(1).text()).toBe(`${mockIssuableListProps.tabCounts.closed}`); }); it('renders contents for slot "nav-actions"', () => { @@ -80,7 +85,7 @@ describe('IssuableTabs', () => { describe('events', () => { it('gl-tab component emits `click` event on `click` event', () => { - const tabEl = wrapper.findAll(GlTab).at(0); + const tabEl = findAllGlTabs().at(0); tabEl.vm.$emit('click', 'opened'); diff --git a/spec/frontend/issuable_list/mock_data.js b/spec/frontend/issuable_list/mock_data.js index 33ffd60bf95..e2fa99f7cc9 100644 --- a/spec/frontend/issuable_list/mock_data.js +++ b/spec/frontend/issuable_list/mock_data.js @@ -135,7 +135,7 @@ export const mockTabs = [ export const mockTabCounts = { opened: 5, closed: 0, - all: 5, + all: undefined, }; export const mockIssuableListProps = { diff --git a/spec/frontend/issuable_show/mock_data.js b/spec/frontend/issuable_show/mock_data.js index 9ecff705617..986d32b4982 100644 --- a/spec/frontend/issuable_show/mock_data.js +++ b/spec/frontend/issuable_show/mock_data.js @@ -32,6 +32,7 @@ export const mockIssuableShowProps = { editFormVisible: false, enableAutocomplete: true, enableAutosave: true, + enableZenMode: true, enableTaskList: true, enableEdit: true, showFieldTitle: false, diff --git a/spec/frontend/issuable_type_selector/components/__snapshots__/info_popover_spec.js.snap b/spec/frontend/issuable_type_selector/components/__snapshots__/info_popover_spec.js.snap new file mode 100644 index 00000000000..196fbb8a643 --- /dev/null +++ b/spec/frontend/issuable_type_selector/components/__snapshots__/info_popover_spec.js.snap @@ -0,0 +1,52 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Issuable type info popover renders 1`] = ` +<span + id="popovercontainer" +> + <gl-icon-stub + class="gl-ml-5 gl-text-gray-500" + id="issuable-type-info" + name="question-o" + size="16" + /> + + <gl-popover-stub + container="popovercontainer" + cssclasses="" + target="issuable-type-info" + title="Issue types" + triggers="focus hover" + > + <ul + class="gl-list-style-none gl-p-0 gl-m-0" + > + <li + class="gl-mb-3" + > + <div + class="gl-font-weight-bold" + > + Issue + </div> + + <span> + For general work + </span> + </li> + + <li> + <div + class="gl-font-weight-bold" + > + Incident + </div> + + <span> + For investigating IT service disruptions or outages + </span> + </li> + </ul> + </gl-popover-stub> +</span> +`; diff --git a/spec/frontend/issuable_type_selector/components/info_popover_spec.js b/spec/frontend/issuable_type_selector/components/info_popover_spec.js new file mode 100644 index 00000000000..975977ffeb3 --- /dev/null +++ b/spec/frontend/issuable_type_selector/components/info_popover_spec.js @@ -0,0 +1,20 @@ +import { shallowMount } from '@vue/test-utils'; +import InfoPopover from '~/issuable_type_selector/components/info_popover.vue'; + +describe('Issuable type info popover', () => { + let wrapper; + + function createComponent() { + wrapper = shallowMount(InfoPopover); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/issue_show/components/edit_actions_spec.js b/spec/frontend/issue_show/components/edit_actions_spec.js index 6a00eec4b1f..54707879f63 100644 --- a/spec/frontend/issue_show/components/edit_actions_spec.js +++ b/spec/frontend/issue_show/components/edit_actions_spec.js @@ -48,7 +48,7 @@ describe('Edit Actions components', () => { vm.formState.title = ''; Vue.nextTick(() => { - expect(vm.$el.querySelector('.btn-success').getAttribute('disabled')).toBe('disabled'); + expect(vm.$el.querySelector('.btn-confirm').getAttribute('disabled')).toBe('disabled'); done(); }); @@ -65,16 +65,16 @@ describe('Edit Actions components', () => { describe('updateIssuable', () => { it('sends update.issauble event when clicking save button', () => { - vm.$el.querySelector('.btn-success').click(); + vm.$el.querySelector('.btn-confirm').click(); expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable'); }); it('disabled button after clicking save button', (done) => { - vm.$el.querySelector('.btn-success').click(); + vm.$el.querySelector('.btn-confirm').click(); Vue.nextTick(() => { - expect(vm.$el.querySelector('.btn-success').getAttribute('disabled')).toBe('disabled'); + expect(vm.$el.querySelector('.btn-confirm').getAttribute('disabled')).toBe('disabled'); done(); }); 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 1053e8934c9..476804bda12 100644 --- a/spec/frontend/issues_list/components/issues_list_app_spec.js +++ b/spec/frontend/issues_list/components/issues_list_app_spec.js @@ -1,19 +1,59 @@ -import { shallowMount } from '@vue/test-utils'; +import { GlButton, GlEmptyState, GlLink } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import IssuableList from '~/issuable_list/components/issuable_list_root.vue'; +import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants'; import IssuesListApp from '~/issues_list/components/issues_list_app.vue'; + +import { + CREATED_DESC, + PAGE_SIZE, + PAGE_SIZE_MANUAL, + RELATIVE_POSITION_ASC, + sortOptions, + sortParams, +} from '~/issues_list/constants'; +import eventHub from '~/issues_list/eventhub'; import axios from '~/lib/utils/axios_utils'; +import { setUrlParams } from '~/lib/utils/url_utility'; + +jest.mock('~/flash'); describe('IssuesListApp component', () => { + const originalWindowLocation = window.location; let axiosMock; let wrapper; - const fullPath = 'path/to/project'; - const endpoint = 'api/endpoint'; + const defaultProvide = { + calendarPath: 'calendar/path', + canBulkUpdate: false, + emptyStateSvgPath: 'empty-state.svg', + endpoint: 'api/endpoint', + exportCsvPath: 'export/csv/path', + fullPath: 'path/to/project', + hasIssues: true, + isSignedIn: false, + issuesPath: 'path/to/issues', + jiraIntegrationPath: 'jira/integration/path', + newIssuePath: 'new/issue/path', + rssPath: 'rss/path', + showImportButton: true, + showNewIssueLink: true, + signInPath: 'sign/in/path', + }; + const state = 'opened'; const xPage = 1; const xTotal = 25; + const tabCounts = { + opened: xTotal, + closed: undefined, + all: undefined, + }; const fetchIssuesResponse = { data: [], headers: { @@ -22,76 +62,484 @@ describe('IssuesListApp component', () => { }, }; + const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons); + const findGlButton = () => wrapper.findComponent(GlButton); + const findGlButtons = () => wrapper.findAllComponents(GlButton); + const findGlButtonAt = (index) => findGlButtons().at(index); + const findGlEmptyState = () => wrapper.findComponent(GlEmptyState); + const findGlLink = () => wrapper.findComponent(GlLink); const findIssuableList = () => wrapper.findComponent(IssuableList); - const mountComponent = () => - shallowMount(IssuesListApp, { + const mountComponent = ({ provide = {}, mountFn = shallowMount } = {}) => + mountFn(IssuesListApp, { provide: { - endpoint, - fullPath, + ...defaultProvide, + ...provide, }, }); - beforeEach(async () => { + beforeEach(() => { axiosMock = new AxiosMockAdapter(axios); - axiosMock.onGet(endpoint).reply(200, fetchIssuesResponse.data, fetchIssuesResponse.headers); - wrapper = mountComponent(); - await waitForPromises(); + axiosMock + .onGet(defaultProvide.endpoint) + .reply(200, fetchIssuesResponse.data, fetchIssuesResponse.headers); }); afterEach(() => { + window.location = originalWindowLocation; axiosMock.reset(); wrapper.destroy(); }); - it('renders IssuableList', () => { - expect(findIssuableList().props()).toMatchObject({ - namespace: fullPath, - recentSearchesStorageKey: 'issues', - searchInputPlaceholder: 'Search or filter results…', - showPaginationControls: true, - issuables: [], - totalItems: xTotal, - currentPage: xPage, - previousPage: xPage - 1, - nextPage: xPage + 1, - urlParams: { page: xPage, state }, + describe('IssuableList', () => { + beforeEach(async () => { + wrapper = mountComponent(); + await waitForPromises(); + }); + + it('renders', () => { + expect(findIssuableList().props()).toMatchObject({ + namespace: defaultProvide.fullPath, + recentSearchesStorageKey: 'issues', + searchInputPlaceholder: 'Search or filter results…', + sortOptions, + initialSortBy: CREATED_DESC, + tabs: IssuableListTabs, + currentTab: IssuableStates.Opened, + tabCounts, + showPaginationControls: false, + issuables: [], + totalItems: xTotal, + currentPage: xPage, + previousPage: xPage - 1, + nextPage: xPage + 1, + urlParams: { page: xPage, state }, + }); }); }); - describe('when "page-change" event is emitted', () => { - const data = [{ id: 10, title: 'title', state }]; - const page = 2; - const totalItems = 21; + describe('header action buttons', () => { + it('renders rss button', () => { + wrapper = mountComponent(); - beforeEach(async () => { - axiosMock.onGet(endpoint).reply(200, data, { - 'x-page': page, - 'x-total': totalItems, + expect(findGlButtonAt(0).attributes()).toMatchObject({ + href: defaultProvide.rssPath, + icon: 'rss', + 'aria-label': IssuesListApp.i18n.rssLabel, + }); + }); + + it('renders calendar button', () => { + wrapper = mountComponent(); + + expect(findGlButtonAt(1).attributes()).toMatchObject({ + href: defaultProvide.calendarPath, + icon: 'calendar', + 'aria-label': IssuesListApp.i18n.calendarLabel, + }); + }); + + it('renders csv import/export component', async () => { + const search = '?page=1&search=refactor'; + + Object.defineProperty(window, 'location', { + writable: true, + value: { search }, }); - findIssuableList().vm.$emit('page-change', page); + wrapper = mountComponent(); await waitForPromises(); + + expect(findCsvImportExportButtons().props()).toMatchObject({ + exportCsvPath: `${defaultProvide.exportCsvPath}${search}`, + issuableCount: xTotal, + }); }); - it('fetches issues with expected params', async () => { - expect(axiosMock.history.get[1].params).toEqual({ - page, - per_page: 20, - state, - with_labels_details: true, + describe('bulk edit button', () => { + it('renders when user has permissions', () => { + wrapper = mountComponent({ provide: { canBulkUpdate: true } }); + + expect(findGlButtonAt(2).text()).toBe('Edit issues'); + }); + + it('does not render when user does not have permissions', () => { + wrapper = mountComponent({ provide: { canBulkUpdate: false } }); + + expect(findGlButtons().filter((button) => button.text() === 'Edit issues')).toHaveLength(0); + }); + + it('emits "issuables:enableBulkEdit" event to legacy bulk edit class', () => { + wrapper = mountComponent({ provide: { canBulkUpdate: true } }); + + jest.spyOn(eventHub, '$emit'); + + findGlButtonAt(2).vm.$emit('click'); + + expect(eventHub.$emit).toHaveBeenCalledWith('issuables:enableBulkEdit'); }); }); - it('updates IssuableList with response data', () => { - expect(findIssuableList().props()).toMatchObject({ - issuables: data, - totalItems, - currentPage: page, - previousPage: page - 1, - nextPage: page + 1, - urlParams: { page, state }, + describe('new issue button', () => { + it('renders when user has permissions', () => { + wrapper = mountComponent({ provide: { showNewIssueLink: true } }); + + expect(findGlButtonAt(2).text()).toBe('New issue'); + expect(findGlButtonAt(2).attributes('href')).toBe(defaultProvide.newIssuePath); + }); + + it('does not render when user does not have permissions', () => { + wrapper = mountComponent({ provide: { showNewIssueLink: false } }); + + expect(findGlButtons().filter((button) => button.text() === 'New issue')).toHaveLength(0); + }); + }); + }); + + describe('initial url params', () => { + describe('page', () => { + it('is set from the url params', () => { + const page = 5; + + Object.defineProperty(window, 'location', { + writable: true, + value: { href: setUrlParams({ page }, TEST_HOST) }, + }); + + wrapper = mountComponent(); + + expect(findIssuableList().props('currentPage')).toBe(page); + }); + }); + + describe('sort', () => { + it.each(Object.keys(sortParams))('is set as %s from the url params', (sortKey) => { + Object.defineProperty(window, 'location', { + writable: true, + value: { href: setUrlParams(sortParams[sortKey], TEST_HOST) }, + }); + + wrapper = mountComponent(); + + expect(findIssuableList().props()).toMatchObject({ + initialSortBy: sortKey, + urlParams: sortParams[sortKey], + }); + }); + }); + + describe('state', () => { + it('is set from the url params', () => { + const initialState = IssuableStates.All; + + Object.defineProperty(window, 'location', { + writable: true, + value: { href: setUrlParams({ state: initialState }, TEST_HOST) }, + }); + + wrapper = mountComponent(); + + expect(findIssuableList().props('currentTab')).toBe(initialState); + }); + }); + }); + + describe('bulk edit', () => { + describe.each([true, false])( + 'when "issuables:toggleBulkEdit" event is received with payload `%s`', + (isBulkEdit) => { + beforeEach(() => { + wrapper = mountComponent(); + + eventHub.$emit('issuables:toggleBulkEdit', isBulkEdit); + }); + + it(`${isBulkEdit ? 'enables' : 'disables'} bulk edit`, () => { + expect(findIssuableList().props('showBulkEditSidebar')).toBe(isBulkEdit); + }); + }, + ); + }); + + describe('empty states', () => { + describe('when there are issues', () => { + describe('when search returns no results', () => { + beforeEach(async () => { + Object.defineProperty(window, 'location', { + writable: true, + value: { href: setUrlParams({ search: 'no results' }, TEST_HOST) }, + }); + + wrapper = mountComponent({ provide: { hasIssues: true } }); + + await waitForPromises(); + }); + + it('shows empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + description: IssuesListApp.i18n.noSearchResultsDescription, + title: IssuesListApp.i18n.noSearchResultsTitle, + svgPath: defaultProvide.emptyStateSvgPath, + }); + }); + }); + + describe('when "Open" tab has no issues', () => { + beforeEach(() => { + wrapper = mountComponent({ provide: { hasIssues: true } }); + }); + + it('shows empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + description: IssuesListApp.i18n.noOpenIssuesDescription, + title: IssuesListApp.i18n.noOpenIssuesTitle, + svgPath: defaultProvide.emptyStateSvgPath, + }); + }); + }); + + describe('when "Closed" tab has no issues', () => { + beforeEach(async () => { + Object.defineProperty(window, 'location', { + writable: true, + value: { href: setUrlParams({ state: IssuableStates.Closed }, TEST_HOST) }, + }); + + wrapper = mountComponent({ provide: { hasIssues: true } }); + }); + + it('shows empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + title: IssuesListApp.i18n.noClosedIssuesTitle, + svgPath: defaultProvide.emptyStateSvgPath, + }); + }); + }); + }); + + describe('when there are no issues', () => { + describe('when user is logged in', () => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { hasIssues: false, isSignedIn: true }, + mountFn: mount, + }); + }); + + it('shows empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + description: IssuesListApp.i18n.noIssuesSignedInDescription, + title: IssuesListApp.i18n.noIssuesSignedInTitle, + svgPath: defaultProvide.emptyStateSvgPath, + }); + }); + + it('shows "New issue" and import/export buttons', () => { + expect(findGlButton().text()).toBe(IssuesListApp.i18n.newIssueLabel); + expect(findGlButton().attributes('href')).toBe(defaultProvide.newIssuePath); + expect(findCsvImportExportButtons().props()).toMatchObject({ + exportCsvPath: defaultProvide.exportCsvPath, + issuableCount: 0, + }); + }); + + it('shows Jira integration information', () => { + const paragraphs = wrapper.findAll('p'); + expect(paragraphs.at(2).text()).toContain(IssuesListApp.i18n.jiraIntegrationTitle); + expect(paragraphs.at(3).text()).toContain( + 'Enable the Jira integration to view your Jira issues in GitLab.', + ); + expect(paragraphs.at(4).text()).toContain( + IssuesListApp.i18n.jiraIntegrationSecondaryMessage, + ); + expect(findGlLink().text()).toBe('Enable the Jira integration'); + expect(findGlLink().attributes('href')).toBe(defaultProvide.jiraIntegrationPath); + }); + }); + + describe('when user is logged out', () => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { hasIssues: false, isSignedIn: false }, + }); + }); + + it('shows empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + description: IssuesListApp.i18n.noIssuesSignedOutDescription, + title: IssuesListApp.i18n.noIssuesSignedOutTitle, + svgPath: defaultProvide.emptyStateSvgPath, + primaryButtonText: IssuesListApp.i18n.noIssuesSignedOutButtonText, + primaryButtonLink: defaultProvide.signInPath, + }); + }); + }); + }); + }); + + describe('events', () => { + describe('when "click-tab" event is emitted by IssuableList', () => { + beforeEach(() => { + axiosMock.onGet(defaultProvide.endpoint).reply(200, fetchIssuesResponse.data, { + 'x-page': 2, + 'x-total': xTotal, + }); + + wrapper = mountComponent(); + + findIssuableList().vm.$emit('click-tab', IssuableStates.Closed); + }); + + it('makes API call to filter the list by the new state and resets the page to 1', () => { + expect(axiosMock.history.get[1].params).toMatchObject({ + page: 1, + state: IssuableStates.Closed, + }); + }); + }); + + describe('when "page-change" event is emitted by IssuableList', () => { + const data = [{ id: 10, title: 'title', state }]; + const page = 2; + const totalItems = 21; + + beforeEach(async () => { + axiosMock.onGet(defaultProvide.endpoint).reply(200, data, { + 'x-page': page, + 'x-total': totalItems, + }); + + wrapper = mountComponent(); + + findIssuableList().vm.$emit('page-change', page); + + await waitForPromises(); + }); + + it('fetches issues with expected params', () => { + expect(axiosMock.history.get[1].params).toEqual({ + page, + per_page: PAGE_SIZE, + state, + with_labels_details: true, + }); + }); + + it('updates IssuableList with response data', () => { + expect(findIssuableList().props()).toMatchObject({ + issuables: data, + totalItems, + currentPage: page, + previousPage: page - 1, + nextPage: page + 1, + urlParams: { page, state }, + }); + }); + }); + + describe('when "reorder" event is emitted by IssuableList', () => { + const issueOne = { id: 1, iid: 101, title: 'Issue one' }; + const issueTwo = { id: 2, iid: 102, title: 'Issue two' }; + const issueThree = { id: 3, iid: 103, title: 'Issue three' }; + const issueFour = { id: 4, iid: 104, title: 'Issue four' }; + const issues = [issueOne, issueTwo, issueThree, issueFour]; + + beforeEach(async () => { + axiosMock.onGet(defaultProvide.endpoint).reply(200, issues, fetchIssuesResponse.headers); + wrapper = mountComponent(); + await waitForPromises(); + }); + + 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: `${defaultProvide.issuesPath}/${issueToMove.iid}/reorder`, + data: JSON.stringify({ move_before_id: moveBeforeId, move_after_id: moveAfterId }), + }); + }); + }, + ); + }); + + describe('when unsuccessful', () => { + it('displays an error message', async () => { + axiosMock.onPut(`${defaultProvide.issuesPath}/${issueOne.iid}/reorder`).reply(500); + + findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 }); + + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ message: IssuesListApp.i18n.reorderError }); + }); + }); + }); + + describe('when "sort" event is emitted by IssuableList', () => { + it.each(Object.keys(sortParams))( + 'fetches issues with correct params with payload `%s`', + async (sortKey) => { + wrapper = mountComponent(); + + findIssuableList().vm.$emit('sort', sortKey); + + await waitForPromises(); + + expect(axiosMock.history.get[1].params).toEqual({ + page: xPage, + per_page: sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE, + state, + with_labels_details: true, + ...sortParams[sortKey], + }); + }, + ); + }); + + describe('when "update-legacy-bulk-edit" event is emitted by IssuableList', () => { + beforeEach(() => { + wrapper = mountComponent(); + jest.spyOn(eventHub, '$emit'); + }); + + it('emits an "issuables:updateBulkEdit" event to the legacy bulk edit class', async () => { + findIssuableList().vm.$emit('update-legacy-bulk-edit'); + + await waitForPromises(); + + expect(eventHub.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit'); + }); + }); + + describe('when "filter" event is emitted by IssuableList', () => { + beforeEach(async () => { + wrapper = mountComponent(); + + const payload = [ + { type: 'filtered-search-term', value: { data: 'no' } }, + { type: 'filtered-search-term', value: { data: 'issues' } }, + ]; + + findIssuableList().vm.$emit('filter', payload); + + await waitForPromises(); + }); + + it('makes an API call to search for issues with the search term', () => { + expect(axiosMock.history.get[1].params).toMatchObject({ search: 'no issues' }); }); }); }); diff --git a/spec/frontend/jira_connect/api_spec.js b/spec/frontend/jira_connect/api_spec.js index 240a57c7917..88922999715 100644 --- a/spec/frontend/jira_connect/api_spec.js +++ b/spec/frontend/jira_connect/api_spec.js @@ -1,8 +1,13 @@ import MockAdapter from 'axios-mock-adapter'; import { addSubscription, removeSubscription, fetchGroups } from '~/jira_connect/api'; +import { getJwt } from '~/jira_connect/utils'; import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; +jest.mock('~/jira_connect/utils', () => ({ + getJwt: jest.fn().mockResolvedValue('jwt'), +})); + describe('JiraConnect API', () => { let mock; let response; @@ -13,14 +18,6 @@ describe('JiraConnect API', () => { const mockJwt = 'jwt'; const mockResponse = { success: true }; - const tokenSpy = jest.fn((callback) => callback(mockJwt)); - - window.AP = { - context: { - getToken: tokenSpy, - }, - }; - beforeEach(() => { mock = new MockAdapter(axios); }); @@ -44,7 +41,7 @@ describe('JiraConnect API', () => { response = await makeRequest(); - expect(tokenSpy).toHaveBeenCalled(); + expect(getJwt).toHaveBeenCalled(); expect(axios.post).toHaveBeenCalledWith(mockAddPath, { jwt: mockJwt, namespace_path: mockNamespace, @@ -62,7 +59,7 @@ describe('JiraConnect API', () => { response = await makeRequest(); - expect(tokenSpy).toHaveBeenCalled(); + expect(getJwt).toHaveBeenCalled(); expect(axios.delete).toHaveBeenCalledWith(mockRemovePath, { params: { jwt: mockJwt, diff --git a/spec/frontend/jira_connect/components/__snapshots__/group_item_name_spec.js.snap b/spec/frontend/jira_connect/components/__snapshots__/group_item_name_spec.js.snap new file mode 100644 index 00000000000..21c903f064d --- /dev/null +++ b/spec/frontend/jira_connect/components/__snapshots__/group_item_name_spec.js.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GroupItemName template matches the snapshot 1`] = ` +<div + class="gl-display-flex gl-align-items-center" +> + <gl-icon-stub + class="gl-mr-3" + name="folder-o" + size="16" + /> + + <div + class="gl-display-none gl-flex-shrink-0 gl-sm-display-flex gl-mr-3" + > + <gl-avatar-stub + alt="avatar" + entityid="0" + entityname="Gitlab Org" + shape="rect" + size="32" + src="avatar.png" + /> + </div> + + <div> + <span + class="gl-mr-3 gl-text-gray-900! gl-font-weight-bold" + > + + Gitlab Org + + </span> + + <div> + <p + class="gl-mt-2! gl-mb-0 gl-text-gray-600" + > + Open source software to collaborate on code + </p> + </div> + </div> +</div> +`; diff --git a/spec/frontend/jira_connect/components/app_spec.js b/spec/frontend/jira_connect/components/app_spec.js index e2a5cd1be9d..e0d61d8209b 100644 --- a/spec/frontend/jira_connect/components/app_spec.js +++ b/spec/frontend/jira_connect/components/app_spec.js @@ -1,50 +1,39 @@ import { GlAlert, GlButton, GlModal, GlLink } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import JiraConnectApp from '~/jira_connect/components/app.vue'; import createStore from '~/jira_connect/store'; import { SET_ALERT } from '~/jira_connect/store/mutation_types'; -import { persistAlert } from '~/jira_connect/utils'; import { __ } from '~/locale'; -jest.mock('~/jira_connect/api'); +jest.mock('~/jira_connect/utils', () => ({ + retrieveAlert: jest.fn().mockReturnValue({ message: 'error message' }), + getLocation: jest.fn(), +})); describe('JiraConnectApp', () => { let wrapper; let store; const findAlert = () => wrapper.findComponent(GlAlert); - const findAlertLink = () => findAlert().find(GlLink); + const findAlertLink = () => findAlert().findComponent(GlLink); const findGlButton = () => wrapper.findComponent(GlButton); const findGlModal = () => wrapper.findComponent(GlModal); - const findHeader = () => wrapper.findByTestId('new-jira-connect-ui-heading'); - const findHeaderText = () => findHeader().text(); const createComponent = ({ provide, mountFn = shallowMount } = {}) => { store = createStore(); - wrapper = extendedWrapper( - mountFn(JiraConnectApp, { - store, - provide, - }), - ); + wrapper = mountFn(JiraConnectApp, { + store, + provide, + }); }; afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('template', () => { - it('renders new UI', () => { - createComponent(); - - expect(findHeader().exists()).toBe(true); - expect(findHeaderText()).toBe('Linked namespaces'); - }); - describe('when user is not logged in', () => { beforeEach(() => { createComponent({ @@ -128,7 +117,6 @@ describe('JiraConnectApp', () => { describe('when alert is set in localStoage', () => { it('renders alert on mount', () => { - persistAlert({ message: 'error message' }); createComponent(); const alert = findAlert(); diff --git a/spec/frontend/jira_connect/components/group_item_name_spec.js b/spec/frontend/jira_connect/components/group_item_name_spec.js new file mode 100644 index 00000000000..ea0067f8ed1 --- /dev/null +++ b/spec/frontend/jira_connect/components/group_item_name_spec.js @@ -0,0 +1,28 @@ +import { shallowMount } from '@vue/test-utils'; + +import GroupItemName from '~/jira_connect/components/group_item_name.vue'; +import { mockGroup1 } from '../mock_data'; + +describe('GroupItemName', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(GroupItemName, { + propsData: { + group: mockGroup1, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + it('matches the snapshot', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + }); +}); diff --git a/spec/frontend/jira_connect/components/groups_list_item_spec.js b/spec/frontend/jira_connect/components/groups_list_item_spec.js index da16223255c..bcc27cc2898 100644 --- a/spec/frontend/jira_connect/components/groups_list_item_spec.js +++ b/spec/frontend/jira_connect/components/groups_list_item_spec.js @@ -1,11 +1,11 @@ -import { GlAvatar, GlButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import * as JiraConnectApi from '~/jira_connect/api'; +import GroupItemName from '~/jira_connect/components/group_item_name.vue'; import GroupsListItem from '~/jira_connect/components/groups_list_item.vue'; -import { persistAlert } from '~/jira_connect/utils'; +import { persistAlert, reloadPage } from '~/jira_connect/utils'; import { mockGroup1 } from '../mock_data'; jest.mock('~/jira_connect/utils'); @@ -14,36 +14,23 @@ describe('GroupsListItem', () => { let wrapper; const mockSubscriptionPath = 'subscriptionPath'; - const reloadSpy = jest.fn(); - - global.AP = { - navigator: { - reload: reloadSpy, - }, - }; - const createComponent = ({ mountFn = shallowMount } = {}) => { - wrapper = extendedWrapper( - mountFn(GroupsListItem, { - propsData: { - group: mockGroup1, - }, - provide: { - subscriptionsPath: mockSubscriptionPath, - }, - }), - ); + wrapper = mountFn(GroupsListItem, { + propsData: { + group: mockGroup1, + }, + provide: { + subscriptionsPath: mockSubscriptionPath, + }, + }); }; afterEach(() => { wrapper.destroy(); - wrapper = null; }); - const findGlAvatar = () => wrapper.find(GlAvatar); - const findGroupName = () => wrapper.findByTestId('group-list-item-name'); - const findGroupDescription = () => wrapper.findByTestId('group-list-item-description'); - const findLinkButton = () => wrapper.find(GlButton); + const findGroupItemName = () => wrapper.findComponent(GroupItemName); + const findLinkButton = () => wrapper.findComponent(GlButton); const clickLinkButton = () => findLinkButton().trigger('click'); describe('template', () => { @@ -51,17 +38,9 @@ describe('GroupsListItem', () => { createComponent(); }); - it('renders group avatar', () => { - expect(findGlAvatar().exists()).toBe(true); - expect(findGlAvatar().props('src')).toBe(mockGroup1.avatar_url); - }); - - it('renders group name', () => { - expect(findGroupName().text()).toBe(mockGroup1.full_name); - }); - - it('renders group description', () => { - expect(findGroupDescription().text()).toBe(mockGroup1.description); + it('renders GroupItemName', () => { + expect(findGroupItemName().exists()).toBe(true); + expect(findGroupItemName().props('group')).toBe(mockGroup1); }); it('renders Link button', () => { @@ -106,7 +85,7 @@ describe('GroupsListItem', () => { await waitForPromises(); - expect(reloadSpy).toHaveBeenCalled(); + expect(reloadPage).toHaveBeenCalled(); }); }); @@ -125,7 +104,7 @@ describe('GroupsListItem', () => { await waitForPromises(); - expect(reloadSpy).not.toHaveBeenCalled(); + expect(reloadPage).not.toHaveBeenCalled(); expect(wrapper.emitted('error')[0][0]).toBe(mockErrorMessage); }); }); diff --git a/spec/frontend/jira_connect/components/groups_list_spec.js b/spec/frontend/jira_connect/components/groups_list_spec.js index 5c645eccc0e..f354cfe6a9b 100644 --- a/spec/frontend/jira_connect/components/groups_list_spec.js +++ b/spec/frontend/jira_connect/components/groups_list_spec.js @@ -1,7 +1,7 @@ -import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; - import { fetchGroups } from '~/jira_connect/api'; import GroupsList from '~/jira_connect/components/groups_list.vue'; import GroupsListItem from '~/jira_connect/components/groups_list_item.vue'; @@ -12,77 +12,100 @@ jest.mock('~/jira_connect/api', () => { fetchGroups: jest.fn(), }; }); + +const mockGroupsPath = '/groups'; + describe('GroupsList', () => { let wrapper; const mockEmptyResponse = { data: [] }; const createComponent = (options = {}) => { - wrapper = shallowMount(GroupsList, { - ...options, - }); + wrapper = extendedWrapper( + shallowMount(GroupsList, { + provide: { + groupsPath: mockGroupsPath, + }, + ...options, + }), + ); }; afterEach(() => { wrapper.destroy(); - wrapper = null; }); - const findGlAlert = () => wrapper.find(GlAlert); - const findGlLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findGlAlert = () => wrapper.findComponent(GlAlert); + const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAllItems = () => wrapper.findAll(GroupsListItem); const findFirstItem = () => findAllItems().at(0); const findSecondItem = () => findAllItems().at(1); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + const findGroupsList = () => wrapper.findByTestId('groups-list'); - describe('isLoading is true', () => { + describe('when groups are loading', () => { it('renders loading icon', async () => { - fetchGroups.mockResolvedValue(mockEmptyResponse); + fetchGroups.mockReturnValue(new Promise(() => {})); createComponent(); - wrapper.setData({ isLoading: true }); await wrapper.vm.$nextTick(); expect(findGlLoadingIcon().exists()).toBe(true); }); }); - describe('error fetching groups', () => { + describe('when groups fetch fails', () => { it('renders error message', async () => { fetchGroups.mockRejectedValue(); createComponent(); await waitForPromises(); + expect(findGlLoadingIcon().exists()).toBe(false); expect(findGlAlert().exists()).toBe(true); expect(findGlAlert().text()).toBe('Failed to load namespaces. Please try again.'); }); }); - describe('no groups returned', () => { + describe('with no groups returned', () => { it('renders empty state', async () => { fetchGroups.mockResolvedValue(mockEmptyResponse); createComponent(); await waitForPromises(); + expect(findGlLoadingIcon().exists()).toBe(false); expect(wrapper.text()).toContain('No available namespaces'); }); }); describe('with groups returned', () => { beforeEach(async () => { - fetchGroups.mockResolvedValue({ data: [mockGroup1, mockGroup2] }); + fetchGroups.mockResolvedValue({ + headers: { 'X-PAGE': 1, 'X-TOTAL': 2 }, + data: [mockGroup1, mockGroup2], + }); createComponent(); await waitForPromises(); }); it('renders groups list', () => { - expect(findAllItems().length).toBe(2); + expect(findAllItems()).toHaveLength(2); expect(findFirstItem().props('group')).toBe(mockGroup1); expect(findSecondItem().props('group')).toBe(mockGroup2); }); + it('sets GroupListItem `disabled` prop to `false`', () => { + findAllItems().wrappers.forEach((groupListItem) => { + expect(groupListItem.props('disabled')).toBe(false); + }); + }); + + it('does not set opacity of the groups list', () => { + expect(findGroupsList().classes()).not.toContain('gl-opacity-5'); + }); + it('shows error message on $emit from item', async () => { const errorMessage = 'error message'; @@ -93,5 +116,55 @@ describe('GroupsList', () => { expect(findGlAlert().exists()).toBe(true); expect(findGlAlert().text()).toContain(errorMessage); }); + + describe('when searching groups', () => { + const mockSearchTeam = 'mock search term'; + + describe('while groups are loading', () => { + beforeEach(async () => { + fetchGroups.mockClear(); + fetchGroups.mockReturnValue(new Promise(() => {})); + + findSearchBox().vm.$emit('input', mockSearchTeam); + await wrapper.vm.$nextTick(); + }); + + it('calls `fetchGroups` with search term', () => { + expect(fetchGroups).toHaveBeenCalledWith(mockGroupsPath, { + page: 1, + perPage: 10, + search: mockSearchTeam, + }); + }); + + it('disables GroupListItems', async () => { + findAllItems().wrappers.forEach((groupListItem) => { + expect(groupListItem.props('disabled')).toBe(true); + }); + }); + + it('sets opacity of the groups list', () => { + expect(findGroupsList().classes()).toContain('gl-opacity-5'); + }); + + it('sets loading prop of ths search box', () => { + expect(findSearchBox().props('isLoading')).toBe(true); + }); + }); + + describe('when group search finishes loading', () => { + beforeEach(async () => { + fetchGroups.mockResolvedValue({ data: [mockGroup1] }); + findSearchBox().vm.$emit('input'); + + await waitForPromises(); + }); + + it('renders new groups list', () => { + expect(findAllItems()).toHaveLength(1); + expect(findFirstItem().props('group')).toBe(mockGroup1); + }); + }); + }); }); }); diff --git a/spec/frontend/jira_connect/components/subscriptions_list_spec.js b/spec/frontend/jira_connect/components/subscriptions_list_spec.js new file mode 100644 index 00000000000..ff86969367d --- /dev/null +++ b/spec/frontend/jira_connect/components/subscriptions_list_spec.js @@ -0,0 +1,122 @@ +import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; + +import * as JiraConnectApi from '~/jira_connect/api'; +import SubscriptionsList from '~/jira_connect/components/subscriptions_list.vue'; +import createStore from '~/jira_connect/store'; +import { SET_ALERT } from '~/jira_connect/store/mutation_types'; +import { reloadPage } from '~/jira_connect/utils'; +import { mockSubscription } from '../mock_data'; + +jest.mock('~/jira_connect/utils'); + +describe('SubscriptionsList', () => { + let wrapper; + let store; + + const createComponent = ({ mountFn = shallowMount, provide = {} } = {}) => { + store = createStore(); + + wrapper = mountFn(SubscriptionsList, { + provide, + store, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findGlEmptyState = () => wrapper.findComponent(GlEmptyState); + const findGlTable = () => wrapper.findComponent(GlTable); + const findUnlinkButton = () => findGlTable().findComponent(GlButton); + const clickUnlinkButton = () => findUnlinkButton().trigger('click'); + + describe('template', () => { + it('renders GlEmptyState when subscriptions is empty', () => { + createComponent(); + + expect(findGlEmptyState().exists()).toBe(true); + expect(findGlTable().exists()).toBe(false); + }); + + it('renders GlTable when subscriptions are present', () => { + createComponent({ + provide: { + subscriptions: [mockSubscription], + }, + }); + + expect(findGlEmptyState().exists()).toBe(false); + expect(findGlTable().exists()).toBe(true); + }); + }); + + describe('on "Unlink" button click', () => { + let removeSubscriptionSpy; + + beforeEach(() => { + createComponent({ + mountFn: mount, + provide: { + subscriptions: [mockSubscription], + }, + }); + removeSubscriptionSpy = jest.spyOn(JiraConnectApi, 'removeSubscription').mockResolvedValue(); + }); + + it('sets button to loading and sends request', async () => { + expect(findUnlinkButton().props('loading')).toBe(false); + + clickUnlinkButton(); + + await wrapper.vm.$nextTick(); + + expect(findUnlinkButton().props('loading')).toBe(true); + + await waitForPromises(); + + expect(removeSubscriptionSpy).toHaveBeenCalledWith(mockSubscription.unlink_path); + }); + + describe('when request is successful', () => { + it('reloads the page', async () => { + clickUnlinkButton(); + + await waitForPromises(); + + expect(reloadPage).toHaveBeenCalled(); + }); + }); + + describe('when request has errors', () => { + const mockErrorMessage = 'error message'; + const mockError = { response: { data: { error: mockErrorMessage } } }; + + beforeEach(() => { + jest.spyOn(JiraConnectApi, 'removeSubscription').mockRejectedValue(mockError); + jest.spyOn(store, 'commit'); + }); + + it('sets alert', async () => { + clickUnlinkButton(); + + await waitForPromises(); + + expect(reloadPage).not.toHaveBeenCalled(); + expect(store.commit.mock.calls).toEqual( + expect.arrayContaining([ + [ + SET_ALERT, + { + message: mockErrorMessage, + variant: 'danger', + }, + ], + ]), + ); + }); + }); + }); +}); diff --git a/spec/frontend/jira_connect/index_spec.js b/spec/frontend/jira_connect/index_spec.js index eb54fe6476f..0161cfa0273 100644 --- a/spec/frontend/jira_connect/index_spec.js +++ b/spec/frontend/jira_connect/index_spec.js @@ -1,27 +1,14 @@ -import waitForPromises from 'helpers/wait_for_promises'; import { initJiraConnect } from '~/jira_connect'; -import { removeSubscription } from '~/jira_connect/api'; -jest.mock('~/jira_connect/api', () => ({ - removeSubscription: jest.fn().mockResolvedValue(), +jest.mock('~/jira_connect/utils', () => ({ getLocation: jest.fn().mockResolvedValue('test/location'), })); describe('initJiraConnect', () => { - window.AP = { - navigator: { - reload: jest.fn(), - }, - }; - beforeEach(async () => { setFixtures(` <a class="js-jira-connect-sign-in" href="https://gitlab.com">Sign In</a> <a class="js-jira-connect-sign-in" href="https://gitlab.com">Another Sign In</a> - - <a href="https://gitlab.com/sub1" class="js-jira-connect-remove-subscription">Remove</a> - <a href="https://gitlab.com/sub2" class="js-jira-connect-remove-subscription">Remove</a> - <a href="https://gitlab.com/sub3" class="js-jira-connect-remove-subscription">Remove</a> `); await initJiraConnect(); @@ -34,23 +21,4 @@ describe('initJiraConnect', () => { }); }); }); - - describe('`remove subscription` buttons', () => { - describe('on click', () => { - it('calls `removeSubscription`', () => { - Array.from(document.querySelectorAll('.js-jira-connect-remove-subscription')).forEach( - (removeSubscriptionButton) => { - removeSubscriptionButton.dispatchEvent(new Event('click')); - - waitForPromises(); - - expect(removeSubscription).toHaveBeenCalledWith(removeSubscriptionButton.href); - expect(removeSubscription).toHaveBeenCalledTimes(1); - - removeSubscription.mockClear(); - }, - ); - }); - }); - }); }); diff --git a/spec/frontend/jira_connect/mock_data.js b/spec/frontend/jira_connect/mock_data.js index 22255fabc3d..5247a3dc522 100644 --- a/spec/frontend/jira_connect/mock_data.js +++ b/spec/frontend/jira_connect/mock_data.js @@ -15,3 +15,9 @@ export const mockGroup2 = { full_path: 'gitlab-com', description: 'For GitLab company related projects', }; + +export const mockSubscription = { + group: mockGroup1, + created_at: '2021-04-14T08:52:23.115Z', + unlink_path: '/-/jira_connect/subscriptions/1', +}; diff --git a/spec/frontend/jira_connect/utils_spec.js b/spec/frontend/jira_connect/utils_spec.js index 5310bce384b..7eae870478d 100644 --- a/spec/frontend/jira_connect/utils_spec.js +++ b/spec/frontend/jira_connect/utils_spec.js @@ -1,11 +1,19 @@ import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import { ALERT_LOCALSTORAGE_KEY } from '~/jira_connect/constants'; -import { persistAlert, retrieveAlert } from '~/jira_connect/utils'; - -useLocalStorageSpy(); +import { + persistAlert, + retrieveAlert, + getJwt, + getLocation, + reloadPage, + sizeToParent, +} from '~/jira_connect/utils'; describe('JiraConnect utils', () => { describe('alert utils', () => { + useLocalStorageSpy(); + it.each` arg | expectedRetrievedValue ${{ title: 'error' }} | ${{ title: 'error' }} @@ -29,4 +37,104 @@ describe('JiraConnect utils', () => { }, ); }); + + describe('AP object utils', () => { + afterEach(() => { + global.AP = null; + }); + + describe('getJwt', () => { + const mockJwt = 'jwt'; + const getTokenSpy = jest.fn((callback) => callback(mockJwt)); + + it('resolves to the function call when AP.context.getToken is a function', async () => { + global.AP = { + context: { + getToken: getTokenSpy, + }, + }; + + const jwt = await getJwt(); + + expect(getTokenSpy).toHaveBeenCalled(); + expect(jwt).toBe(mockJwt); + }); + + it('resolves to undefined when AP.context.getToken is not a function', async () => { + const jwt = await getJwt(); + + expect(getTokenSpy).not.toHaveBeenCalled(); + expect(jwt).toBeUndefined(); + }); + }); + + describe('getLocation', () => { + const mockLocation = 'test/location'; + const getLocationSpy = jest.fn((callback) => callback(mockLocation)); + + it('resolves to the function call when AP.getLocation is a function', async () => { + global.AP = { + getLocation: getLocationSpy, + }; + + const location = await getLocation(); + + expect(getLocationSpy).toHaveBeenCalled(); + expect(location).toBe(mockLocation); + }); + + it('resolves to undefined when AP.getLocation is not a function', async () => { + const location = await getLocation(); + + expect(getLocationSpy).not.toHaveBeenCalled(); + expect(location).toBeUndefined(); + }); + }); + + describe('reloadPage', () => { + const reloadSpy = jest.fn(); + + useMockLocationHelper(); + + it('calls the function when AP.navigator.reload is a function', async () => { + global.AP = { + navigator: { + reload: reloadSpy, + }, + }; + + await reloadPage(); + + expect(reloadSpy).toHaveBeenCalled(); + expect(window.location.reload).not.toHaveBeenCalled(); + }); + + it('calls window.location.reload when AP.navigator.reload is not a function', async () => { + await reloadPage(); + + expect(reloadSpy).not.toHaveBeenCalled(); + expect(window.location.reload).toHaveBeenCalled(); + }); + }); + + describe('sizeToParent', () => { + const sizeToParentSpy = jest.fn(); + + it('calls the function when AP.sizeToParent is a function', async () => { + global.AP = { + sizeToParent: sizeToParentSpy, + }; + + await sizeToParent(); + + expect(sizeToParentSpy).toHaveBeenCalled(); + }); + + it('does nothing when AP.navigator.reload is not a function', async () => { + await sizeToParent(); + + expect(sizeToParentSpy).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/spec/frontend/jobs/components/commit_block_spec.js b/spec/frontend/jobs/components/commit_block_spec.js index 13261317b48..8a6d48cecb8 100644 --- a/spec/frontend/jobs/components/commit_block_spec.js +++ b/spec/frontend/jobs/components/commit_block_spec.js @@ -1,89 +1,70 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import component from '~/jobs/components/commit_block.vue'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import CommitBlock from '~/jobs/components/commit_block.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; describe('Commit block', () => { - const Component = Vue.extend(component); - let vm; + let wrapper; - const props = { - commit: { - short_id: '1f0fb84f', - id: '1f0fb84fb6770d74d97eee58118fd3909cd4f48c', - commit_path: 'commit/1f0fb84fb6770d74d97eee58118fd3909cd4f48c', - title: 'Update README.md', - }, - mergeRequest: { - iid: '!21244', - path: 'merge_requests/21244', - }, - isLastBlock: true, + const commit = { + short_id: '1f0fb84f', + id: '1f0fb84fb6770d74d97eee58118fd3909cd4f48c', + commit_path: 'commit/1f0fb84fb6770d74d97eee58118fd3909cd4f48c', + title: 'Update README.md', + }; + + const mergeRequest = { + iid: '!21244', + path: 'merge_requests/21244', + }; + + const findCommitSha = () => wrapper.findByTestId('commit-sha'); + const findLinkSha = () => wrapper.findByTestId('link-commit'); + + const mountComponent = (props) => { + wrapper = extendedWrapper( + shallowMount(CommitBlock, { + propsData: { + commit, + ...props, + }, + }), + ); }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); - describe('pipeline short sha', () => { + describe('without merge request', () => { beforeEach(() => { - vm = mountComponent(Component, { - ...props, - }); + mountComponent(); }); it('renders pipeline short sha link', () => { - expect(vm.$el.querySelector('.js-commit-sha').getAttribute('href')).toEqual( - props.commit.commit_path, - ); - - expect(vm.$el.querySelector('.js-commit-sha').textContent.trim()).toEqual( - props.commit.short_id, - ); + expect(findCommitSha().attributes('href')).toBe(commit.commit_path); + expect(findCommitSha().text()).toBe(commit.short_id); }); it('renders clipboard button', () => { - expect(vm.$el.querySelector('button').getAttribute('data-clipboard-text')).toEqual( - props.commit.id, - ); + expect(wrapper.findComponent(ClipboardButton).attributes('text')).toBe(commit.id); }); - }); - - describe('with merge request', () => { - it('renders merge request link and reference', () => { - vm = mountComponent(Component, { - ...props, - }); - - expect(vm.$el.querySelector('.js-link-commit').getAttribute('href')).toEqual( - props.mergeRequest.path, - ); - expect(vm.$el.querySelector('.js-link-commit').textContent.trim()).toEqual( - `!${props.mergeRequest.iid}`, - ); + it('renders git commit title', () => { + expect(wrapper.text()).toContain(commit.title); }); - }); - describe('without merge request', () => { it('does not render merge request', () => { - const copyProps = { ...props }; - delete copyProps.mergeRequest; - - vm = mountComponent(Component, { - ...copyProps, - }); - - expect(vm.$el.querySelector('.js-link-commit')).toBeNull(); + expect(findLinkSha().exists()).toBe(false); }); }); - describe('git commit title', () => { - it('renders git commit title', () => { - vm = mountComponent(Component, { - ...props, - }); + describe('with merge request', () => { + it('renders merge request link and reference', () => { + mountComponent({ mergeRequest }); - expect(vm.$el.textContent).toContain(props.commit.title); + expect(findLinkSha().attributes('href')).toBe(mergeRequest.path); + expect(findLinkSha().text()).toBe(`!${mergeRequest.iid}`); }); }); }); diff --git a/spec/frontend/jobs/components/job_sidebar_details_container_spec.js b/spec/frontend/jobs/components/job_sidebar_details_container_spec.js index 2b56bd2d558..ad0368555fa 100644 --- a/spec/frontend/jobs/components/job_sidebar_details_container_spec.js +++ b/spec/frontend/jobs/components/job_sidebar_details_container_spec.js @@ -34,11 +34,22 @@ describe('Job Sidebar Details Container', () => { }); describe('when no details are available', () => { - it('should render an empty container', () => { + beforeEach(() => { createWrapper(); + }); + it('should render an empty container', () => { expect(wrapper.html()).toBe(''); }); + + it.each(['duration', 'erased_at', 'finished_at', 'queued', 'runner', 'coverage'])( + 'should not render %s details when missing', + async (detail) => { + await store.dispatch('receiveJobSuccess', { [detail]: undefined }); + + expect(findAllDetailsRow()).toHaveLength(0); + }, + ); }); describe('when some of the details are available', () => { @@ -49,7 +60,7 @@ describe('Job Sidebar Details Container', () => { ['erased_at', 'Erased: 3 weeks ago'], ['finished_at', 'Finished: 3 weeks ago'], ['queued', 'Queued: 9 seconds'], - ['runner', 'Runner: local ci runner (#1)'], + ['runner', 'Runner: #1 (ABCDEFGH) local ci runner'], ['coverage', 'Coverage: 20%'], ])('uses %s to render job-%s', async (detail, value) => { await store.dispatch('receiveJobSuccess', { [detail]: job[detail] }); diff --git a/spec/frontend/jobs/components/manual_variables_form_spec.js b/spec/frontend/jobs/components/manual_variables_form_spec.js index 7172a319876..376a822dde5 100644 --- a/spec/frontend/jobs/components/manual_variables_form_spec.js +++ b/spec/frontend/jobs/components/manual_variables_form_spec.js @@ -1,11 +1,16 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import Form from '~/jobs/components/manual_variables_form.vue'; const localVue = createLocalVue(); +Vue.use(Vuex); + describe('Manual Variables Form', () => { let wrapper; + let store; const requiredProps = { action: { @@ -16,88 +21,104 @@ describe('Manual Variables Form', () => { variablesSettingsUrl: '/settings', }; - const factory = (props = {}) => { - wrapper = shallowMount(localVue.extend(Form), { - propsData: props, - localVue, + const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { + store = new Vuex.Store({ + actions: { + triggerManualJob: jest.fn(), + }, }); + + wrapper = extendedWrapper( + mountFn(localVue.extend(Form), { + propsData: { ...requiredProps, ...props }, + localVue, + store, + }), + ); }; - beforeEach(() => { - factory(requiredProps); - }); + const findInputKey = () => wrapper.findComponent({ ref: 'inputKey' }); + const findInputValue = () => wrapper.findComponent({ ref: 'inputSecretValue' }); - afterEach((done) => { - // The component has a `nextTick` callback after some events so we need - // to wait for those to finish before destroying. - setImmediate(() => { - wrapper.destroy(); - wrapper = null; + const findTriggerBtn = () => wrapper.findByTestId('trigger-manual-job-btn'); + const findHelpText = () => wrapper.findByTestId('form-help-text'); + const findDeleteVarBtn = () => wrapper.findByTestId('delete-variable-btn'); + const findCiVariableKey = () => wrapper.findByTestId('ci-variable-key'); + const findCiVariableValue = () => wrapper.findByTestId('ci-variable-value'); + const findAllVariables = () => wrapper.findAllByTestId('ci-variable-row'); - done(); - }); + afterEach(() => { + wrapper.destroy(); }); - it('renders empty form with correct placeholders', () => { - expect(wrapper.find({ ref: 'inputKey' }).attributes('placeholder')).toBe('Input variable key'); - expect(wrapper.find({ ref: 'inputSecretValue' }).attributes('placeholder')).toBe( - 'Input variable value', - ); - }); + describe('shallowMount', () => { + beforeEach(() => { + createComponent(); + }); - it('renders help text with provided link', () => { - expect(wrapper.find('p').text()).toBe( - 'Specify variable values to be used in this run. The values specified in CI/CD settings will be used as default', - ); + it('renders empty form with correct placeholders', () => { + expect(findInputKey().attributes('placeholder')).toBe('Input variable key'); + expect(findInputValue().attributes('placeholder')).toBe('Input variable value'); + }); - expect(wrapper.find('a').attributes('href')).toBe(requiredProps.variablesSettingsUrl); - }); + it('renders help text with provided link', () => { + expect(findHelpText().text()).toBe( + 'Specify variable values to be used in this run. The values specified in CI/CD settings will be used as default', + ); - describe('when adding a new variable', () => { - it('creates a new variable when user types a new key and resets the form', (done) => { - wrapper.vm - .$nextTick() - .then(() => wrapper.find({ ref: 'inputKey' }).setValue('new key')) - .then(() => { - expect(wrapper.vm.variables.length).toBe(1); - expect(wrapper.vm.variables[0].key).toBe('new key'); - expect(wrapper.find({ ref: 'inputKey' }).attributes('value')).toBe(undefined); - }) - .then(done) - .catch(done.fail); + expect(wrapper.find('a').attributes('href')).toBe(requiredProps.variablesSettingsUrl); }); - it('creates a new variable when user types a new value and resets the form', (done) => { - wrapper.vm - .$nextTick() - .then(() => wrapper.find({ ref: 'inputSecretValue' }).setValue('new value')) - .then(() => { - expect(wrapper.vm.variables.length).toBe(1); - expect(wrapper.vm.variables[0].secret_value).toBe('new value'); - expect(wrapper.find({ ref: 'inputSecretValue' }).attributes('value')).toBe(undefined); - }) - .then(done) - .catch(done.fail); + describe('when adding a new variable', () => { + it('creates a new variable when user types a new key and resets the form', async () => { + await findInputKey().setValue('new key'); + + expect(findAllVariables()).toHaveLength(1); + expect(findCiVariableKey().element.value).toBe('new key'); + expect(findInputKey().attributes('value')).toBe(undefined); + }); + + it('creates a new variable when user types a new value and resets the form', async () => { + await findInputValue().setValue('new value'); + + expect(findAllVariables()).toHaveLength(1); + expect(findCiVariableValue().element.value).toBe('new value'); + expect(findInputValue().attributes('value')).toBe(undefined); + }); }); }); - describe('when deleting a variable', () => { - beforeEach((done) => { - wrapper.vm.variables = [ - { - key: 'new key', - secret_value: 'value', - id: '1', - }, - ]; - - wrapper.vm.$nextTick(done); + describe('mount', () => { + beforeEach(() => { + createComponent({ mountFn: mount }); + }); + + describe('when deleting a variable', () => { + it('removes the variable row', async () => { + await wrapper.setData({ + variables: [ + { + key: 'new key', + secret_value: 'value', + id: '1', + }, + ], + }); + + findDeleteVarBtn().trigger('click'); + + await wrapper.vm.$nextTick(); + + expect(findAllVariables()).toHaveLength(0); + }); }); - it('removes the variable row', () => { - wrapper.find(GlButton).vm.$emit('click'); + it('trigger button is disabled after trigger action', async () => { + expect(findTriggerBtn().props('disabled')).toBe(false); + + await findTriggerBtn().trigger('click'); - expect(wrapper.vm.variables.length).toBe(0); + expect(findTriggerBtn().props('disabled')).toBe(true); }); }); }); diff --git a/spec/frontend/jobs/components/sidebar_spec.js b/spec/frontend/jobs/components/sidebar_spec.js index 5a2e699137d..500a1b48950 100644 --- a/spec/frontend/jobs/components/sidebar_spec.js +++ b/spec/frontend/jobs/components/sidebar_spec.js @@ -1,5 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import ArtifactsBlock from '~/jobs/components/artifacts_block.vue'; import JobRetryForwardDeploymentModal from '~/jobs/components/job_retry_forward_deployment_modal.vue'; import JobRetryButton from '~/jobs/components/job_sidebar_retry_button.vue'; import JobsContainer from '~/jobs/components/jobs_container.vue'; @@ -14,6 +15,7 @@ describe('Sidebar details block', () => { const forwardDeploymentFailure = 'forward_deployment_failure'; const findModal = () => wrapper.find(JobRetryForwardDeploymentModal); + const findArtifactsBlock = () => wrapper.findComponent(ArtifactsBlock); const findCancelButton = () => wrapper.findByTestId('cancel-button'); const findNewIssueButton = () => wrapper.findByTestId('job-new-issue'); const findRetryButton = () => wrapper.find(JobRetryButton); @@ -21,6 +23,9 @@ describe('Sidebar details block', () => { const createWrapper = ({ props = {} } = {}) => { store = createStore(); + + store.state.job = job; + wrapper = extendedWrapper( shallowMount(Sidebar, { ...props, @@ -164,4 +169,29 @@ describe('Sidebar details block', () => { }); }); }); + + describe('artifacts', () => { + beforeEach(() => { + createWrapper(); + }); + + it('artifacts are not shown if there are no properties other than locked', () => { + expect(findArtifactsBlock().exists()).toBe(false); + }); + + it('artifacts are shown if present', async () => { + store.state.job.artifact = { + download_path: '/root/ci-project/-/jobs/1960/artifacts/download', + browse_path: '/root/ci-project/-/jobs/1960/artifacts/browse', + keep_path: '/root/ci-project/-/jobs/1960/artifacts/keep', + expire_at: '2021-03-23T17:57:11.211Z', + expired: false, + locked: false, + }; + + await wrapper.vm.$nextTick(); + + expect(findArtifactsBlock().exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/jobs/components/stages_dropdown_spec.js b/spec/frontend/jobs/components/stages_dropdown_spec.js index 72d5d0f9d44..b75d1707a8d 100644 --- a/spec/frontend/jobs/components/stages_dropdown_spec.js +++ b/spec/frontend/jobs/components/stages_dropdown_spec.js @@ -1,163 +1,134 @@ -import Vue from 'vue'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import { trimText } from 'helpers/text_helper'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import component from '~/jobs/components/stages_dropdown.vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import StagesDropdown from '~/jobs/components/stages_dropdown.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { + mockPipelineWithoutMR, + mockPipelineWithAttachedMR, + mockPipelineDetached, +} from '../mock_data'; describe('Stages Dropdown', () => { - const Component = Vue.extend(component); - let vm; - - const mockPipelineData = { - id: 28029444, - details: { - status: { - details_path: '/gitlab-org/gitlab-foss/pipelines/28029444', - group: 'success', - has_details: true, - icon: 'status_success', - label: 'passed', - text: 'passed', - tooltip: 'passed', - }, - }, - path: 'pipeline/28029444', - flags: { - merge_request_pipeline: true, - detached_merge_request_pipeline: false, - }, - merge_request: { - iid: 1234, - path: '/root/detached-merge-request-pipelines/-/merge_requests/1', - title: 'Update README.md', - source_branch: 'feature-1234', - source_branch_path: '/root/detached-merge-request-pipelines/branches/feature-1234', - target_branch: 'master', - target_branch_path: '/root/detached-merge-request-pipelines/branches/master', - }, - ref: { - name: 'test-branch', - }, + let wrapper; + + const findStatus = () => wrapper.findComponent(CiIcon); + const findSelectedStageText = () => wrapper.findComponent(GlDropdown).props('text'); + const findStageItem = (index) => wrapper.findAllComponents(GlDropdownItem).at(index); + + const findPipelineInfoText = () => wrapper.findByTestId('pipeline-info').text(); + const findPipelinePath = () => wrapper.findByTestId('pipeline-path').attributes('href'); + const findMRLinkPath = () => wrapper.findByTestId('mr-link').attributes('href'); + const findSourceBranchLinkPath = () => + wrapper.findByTestId('source-branch-link').attributes('href'); + const findTargetBranchLinkPath = () => + wrapper.findByTestId('target-branch-link').attributes('href'); + + const createComponent = (props) => { + wrapper = extendedWrapper( + shallowMount(StagesDropdown, { + propsData: { + ...props, + }, + }), + ); }; - describe('without a merge request pipeline', () => { - let pipeline; + afterEach(() => { + wrapper.destroy(); + }); + describe('without a merge request pipeline', () => { beforeEach(() => { - pipeline = JSON.parse(JSON.stringify(mockPipelineData)); - delete pipeline.merge_request; - delete pipeline.flags.merge_request_pipeline; - delete pipeline.flags.detached_merge_request_pipeline; - - vm = mountComponent(Component, { - pipeline, + createComponent({ + pipeline: mockPipelineWithoutMR, stages: [{ name: 'build' }, { name: 'test' }], selectedStage: 'deploy', }); }); - afterEach(() => { - vm.$destroy(); - }); - it('renders pipeline status', () => { - expect(vm.$el.querySelector('.js-ci-status-icon-success')).not.toBeNull(); + expect(findStatus().exists()).toBe(true); }); it('renders pipeline link', () => { - expect(vm.$el.querySelector('.js-pipeline-path').getAttribute('href')).toEqual( - 'pipeline/28029444', - ); + expect(findPipelinePath()).toBe('pipeline/28029444'); }); it('renders dropdown with stages', () => { - expect(vm.$el.querySelector('.dropdown .js-stage-item').textContent).toContain('build'); + expect(findStageItem(0).text()).toBe('build'); }); it('rendes selected stage', () => { - expect(vm.$el.querySelector('.dropdown .js-selected-stage').textContent).toContain('deploy'); + expect(findSelectedStageText()).toBe('deploy'); }); it(`renders the pipeline info text like "Pipeline #123 for source_branch"`, () => { - const expected = `Pipeline #${pipeline.id} for ${pipeline.ref.name}`; - const actual = trimText(vm.$el.querySelector('.js-pipeline-info').innerText); + const expected = `Pipeline #${mockPipelineWithoutMR.id} for ${mockPipelineWithoutMR.ref.name}`; + const actual = trimText(findPipelineInfoText()); expect(actual).toBe(expected); }); }); describe('with an "attached" merge request pipeline', () => { - let pipeline; - beforeEach(() => { - pipeline = JSON.parse(JSON.stringify(mockPipelineData)); - pipeline.flags.merge_request_pipeline = true; - pipeline.flags.detached_merge_request_pipeline = false; - - vm = mountComponent(Component, { - pipeline, + createComponent({ + pipeline: mockPipelineWithAttachedMR, stages: [], selectedStage: 'deploy', }); }); it(`renders the pipeline info text like "Pipeline #123 for !456 with source_branch into target_branch"`, () => { - const expected = `Pipeline #${pipeline.id} for !${pipeline.merge_request.iid} with ${pipeline.merge_request.source_branch} into ${pipeline.merge_request.target_branch}`; - const actual = trimText(vm.$el.querySelector('.js-pipeline-info').innerText); + const expected = `Pipeline #${mockPipelineWithAttachedMR.id} for !${mockPipelineWithAttachedMR.merge_request.iid} with ${mockPipelineWithAttachedMR.merge_request.source_branch} into ${mockPipelineWithAttachedMR.merge_request.target_branch}`; + const actual = trimText(findPipelineInfoText()); expect(actual).toBe(expected); }); it(`renders the correct merge request link`, () => { - const actual = vm.$el.querySelector('.js-mr-link').href; - - expect(actual).toContain(pipeline.merge_request.path); + expect(findMRLinkPath()).toBe(mockPipelineWithAttachedMR.merge_request.path); }); it(`renders the correct source branch link`, () => { - const actual = vm.$el.querySelector('.js-source-branch-link').href; - - expect(actual).toContain(pipeline.merge_request.source_branch_path); + expect(findSourceBranchLinkPath()).toBe( + mockPipelineWithAttachedMR.merge_request.source_branch_path, + ); }); it(`renders the correct target branch link`, () => { - const actual = vm.$el.querySelector('.js-target-branch-link').href; - - expect(actual).toContain(pipeline.merge_request.target_branch_path); + expect(findTargetBranchLinkPath()).toBe( + mockPipelineWithAttachedMR.merge_request.target_branch_path, + ); }); }); describe('with a detached merge request pipeline', () => { - let pipeline; - beforeEach(() => { - pipeline = JSON.parse(JSON.stringify(mockPipelineData)); - pipeline.flags.merge_request_pipeline = false; - pipeline.flags.detached_merge_request_pipeline = true; - - vm = mountComponent(Component, { - pipeline, + createComponent({ + pipeline: mockPipelineDetached, stages: [], selectedStage: 'deploy', }); }); it(`renders the pipeline info like "Pipeline #123 for !456 with source_branch"`, () => { - const expected = `Pipeline #${pipeline.id} for !${pipeline.merge_request.iid} with ${pipeline.merge_request.source_branch}`; - const actual = trimText(vm.$el.querySelector('.js-pipeline-info').innerText); + const expected = `Pipeline #${mockPipelineDetached.id} for !${mockPipelineDetached.merge_request.iid} with ${mockPipelineDetached.merge_request.source_branch}`; + const actual = trimText(findPipelineInfoText()); expect(actual).toBe(expected); }); it(`renders the correct merge request link`, () => { - const actual = vm.$el.querySelector('.js-mr-link').href; - - expect(actual).toContain(pipeline.merge_request.path); + expect(findMRLinkPath()).toBe(mockPipelineDetached.merge_request.path); }); it(`renders the correct source branch link`, () => { - const actual = vm.$el.querySelector('.js-source-branch-link').href; - - expect(actual).toContain(pipeline.merge_request.source_branch_path); + expect(findSourceBranchLinkPath()).toBe( + mockPipelineDetached.merge_request.source_branch_path, + ); }); }); }); diff --git a/spec/frontend/jobs/components/table/jobs_table_spec.js b/spec/frontend/jobs/components/table/jobs_table_spec.js new file mode 100644 index 00000000000..db057efbfb4 --- /dev/null +++ b/spec/frontend/jobs/components/table/jobs_table_spec.js @@ -0,0 +1,31 @@ +import { GlTable } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import JobsTable from '~/jobs/components/table/jobs_table.vue'; +import { mockJobsInTable } from '../../mock_data'; + +describe('Jobs Table', () => { + let wrapper; + + const findTable = () => wrapper.findComponent(GlTable); + + const createComponent = (props = {}) => { + wrapper = shallowMount(JobsTable, { + propsData: { + jobs: mockJobsInTable, + ...props, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays a table', () => { + expect(findTable().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/jobs/components/table/jobs_table_tabs_spec.js b/spec/frontend/jobs/components/table/jobs_table_tabs_spec.js new file mode 100644 index 00000000000..ac9b45be932 --- /dev/null +++ b/spec/frontend/jobs/components/table/jobs_table_tabs_spec.js @@ -0,0 +1,42 @@ +import { mount } from '@vue/test-utils'; +import { trimText } from 'helpers/text_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue'; + +describe('Jobs Table Tabs', () => { + let wrapper; + + const defaultProps = { + jobCounts: { all: 848, pending: 0, running: 0, finished: 704 }, + }; + + const findTab = (testId) => wrapper.findByTestId(testId); + + const createComponent = () => { + wrapper = extendedWrapper( + mount(JobsTableTabs, { + provide: { + ...defaultProps, + }, + }), + ); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + tabId | text | count + ${'jobs-all-tab'} | ${'All'} | ${defaultProps.jobCounts.all} + ${'jobs-pending-tab'} | ${'Pending'} | ${defaultProps.jobCounts.pending} + ${'jobs-running-tab'} | ${'Running'} | ${defaultProps.jobCounts.running} + ${'jobs-finished-tab'} | ${'Finished'} | ${defaultProps.jobCounts.finished} + `('displays the right tab text and badge count', ({ tabId, text, count }) => { + expect(trimText(findTab(tabId).text())).toBe(`${text} ${count}`); + }); +}); diff --git a/spec/frontend/jobs/mock_data.js b/spec/frontend/jobs/mock_data.js index 3d40e94d219..1432c6d7e9b 100644 --- a/spec/frontend/jobs/mock_data.js +++ b/spec/frontend/jobs/mock_data.js @@ -911,6 +911,9 @@ export const stages = [ export default { id: 4757, + artifact: { + locked: false, + }, name: 'test', build_path: '/root/ci-mock/-/jobs/4757', retry_path: '/root/ci-mock/-/jobs/4757/retry', @@ -955,6 +958,7 @@ export default { artifacts: [null], runner: { id: 1, + short_sha: 'ABCDEFGH', description: 'local ci runner', edit_path: '/root/ci-mock/runners/1/edit', }, @@ -1189,3 +1193,214 @@ export const jobsInStage = { path: '/gitlab-org/gitlab-shell/pipelines/27#build', dropdown_path: '/gitlab-org/gitlab-shell/pipelines/27/stage.json?stage=build', }; + +export const mockPipelineWithoutMR = { + id: 28029444, + details: { + status: { + details_path: '/gitlab-org/gitlab-foss/pipelines/28029444', + group: 'success', + has_details: true, + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed', + }, + }, + path: 'pipeline/28029444', + ref: { + name: 'test-branch', + }, +}; + +export const mockPipelineWithAttachedMR = { + id: 28029444, + details: { + status: { + details_path: '/gitlab-org/gitlab-foss/pipelines/28029444', + group: 'success', + has_details: true, + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed', + }, + }, + path: 'pipeline/28029444', + flags: { + merge_request_pipeline: true, + detached_merge_request_pipeline: false, + }, + merge_request: { + iid: 1234, + path: '/root/detached-merge-request-pipelines/-/merge_requests/1', + title: 'Update README.md', + source_branch: 'feature-1234', + source_branch_path: '/root/detached-merge-request-pipelines/branches/feature-1234', + target_branch: 'master', + target_branch_path: '/root/detached-merge-request-pipelines/branches/master', + }, + ref: { + name: 'test-branch', + }, +}; + +export const mockPipelineDetached = { + id: 28029444, + details: { + status: { + details_path: '/gitlab-org/gitlab-foss/pipelines/28029444', + group: 'success', + has_details: true, + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed', + }, + }, + path: 'pipeline/28029444', + flags: { + merge_request_pipeline: false, + detached_merge_request_pipeline: true, + }, + merge_request: { + iid: 1234, + path: '/root/detached-merge-request-pipelines/-/merge_requests/1', + title: 'Update README.md', + source_branch: 'feature-1234', + source_branch_path: '/root/detached-merge-request-pipelines/branches/feature-1234', + target_branch: 'master', + target_branch_path: '/root/detached-merge-request-pipelines/branches/master', + }, + ref: { + name: 'test-branch', + }, +}; + +export const mockJobsInTable = [ + { + detailedStatus: { + icon: 'status_manual', + label: 'manual play action', + text: 'manual', + tooltip: 'manual action', + action: { + buttonTitle: 'Trigger this manual action', + icon: 'play', + method: 'post', + path: '/root/ci-project/-/jobs/2004/play', + title: 'Play', + __typename: 'StatusAction', + }, + __typename: 'DetailedStatus', + }, + id: 'gid://gitlab/Ci::Build/2004', + refName: 'master', + refPath: '/root/ci-project/-/commits/master', + tags: [], + shortSha: '2d5d8323', + commitPath: '/root/ci-project/-/commit/2d5d83230bdea0e003d83ef4c16d2bf9a8808ebe', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/423', + path: '/root/ci-project/-/pipelines/423', + user: { + webPath: '/root', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + __typename: 'User', + }, + __typename: 'Pipeline', + }, + stage: { name: 'test', __typename: 'CiStage' }, + name: 'test_manual_job', + duration: null, + finishedAt: null, + coverage: null, + retryable: false, + playable: true, + cancelable: false, + active: false, + __typename: 'CiJob', + }, + { + detailedStatus: { + icon: 'status_skipped', + label: 'skipped', + text: 'skipped', + tooltip: 'skipped', + action: null, + __typename: 'DetailedStatus', + }, + id: 'gid://gitlab/Ci::Build/2021', + refName: 'master', + refPath: '/root/ci-project/-/commits/master', + tags: [], + shortSha: '2d5d8323', + commitPath: '/root/ci-project/-/commit/2d5d83230bdea0e003d83ef4c16d2bf9a8808ebe', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/425', + path: '/root/ci-project/-/pipelines/425', + user: { + webPath: '/root', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + __typename: 'User', + }, + __typename: 'Pipeline', + }, + stage: { name: 'test', __typename: 'CiStage' }, + name: 'coverage_job', + duration: null, + finishedAt: null, + coverage: null, + retryable: false, + playable: false, + cancelable: false, + active: false, + __typename: 'CiJob', + }, + { + detailedStatus: { + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed', + action: { + buttonTitle: 'Retry this job', + icon: 'retry', + method: 'post', + path: '/root/ci-project/-/jobs/2015/retry', + title: 'Retry', + __typename: 'StatusAction', + }, + __typename: 'DetailedStatus', + }, + id: 'gid://gitlab/Ci::Build/2015', + refName: 'master', + refPath: '/root/ci-project/-/commits/master', + tags: [], + shortSha: '2d5d8323', + commitPath: '/root/ci-project/-/commit/2d5d83230bdea0e003d83ef4c16d2bf9a8808ebe', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/424', + path: '/root/ci-project/-/pipelines/424', + user: { + webPath: '/root', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + __typename: 'User', + }, + __typename: 'Pipeline', + }, + stage: { name: 'deploy', __typename: 'CiStage' }, + name: 'artifact_job', + duration: 2, + finishedAt: '2021-04-01T17:36:18Z', + coverage: null, + retryable: true, + playable: false, + cancelable: false, + active: false, + __typename: 'CiJob', + }, +]; diff --git a/spec/frontend/lib/utils/color_utils_spec.js b/spec/frontend/lib/utils/color_utils_spec.js index 8c846abd77f..c6b88b2957c 100644 --- a/spec/frontend/lib/utils/color_utils_spec.js +++ b/spec/frontend/lib/utils/color_utils_spec.js @@ -1,4 +1,9 @@ -import { textColorForBackground, hexToRgb, validateHexColor } from '~/lib/utils/color_utils'; +import { + textColorForBackground, + hexToRgb, + validateHexColor, + darkModeEnabled, +} from '~/lib/utils/color_utils'; describe('Color utils', () => { describe('Converting hex code to rgb', () => { @@ -47,4 +52,24 @@ describe('Color utils', () => { expect(validateHexColor(color)).toEqual(output); }); }); + + describe('darkModeEnabled', () => { + it.each` + page | bodyClass | ideTheme | expected + ${'ide:index'} | ${'gl-dark'} | ${'monokai-light'} | ${false} + ${'ide:index'} | ${'ui-light'} | ${'monokai'} | ${true} + ${'groups:issues:index'} | ${'ui-light'} | ${'monokai'} | ${false} + ${'groups:issues:index'} | ${'gl-dark'} | ${'monokai-light'} | ${true} + `( + 'is $expected on $page with $bodyClass body class and $ideTheme IDE theme', + async ({ page, bodyClass, ideTheme, expected }) => { + document.body.outerHTML = `<body class="${bodyClass}" data-page="${page}"></body>`; + window.gon = { + user_color_scheme: ideTheme, + }; + + expect(darkModeEnabled()).toBe(expected); + }, + ); + }); }); diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js index 18be88a0b8b..e03d1ef7295 100644 --- a/spec/frontend/lib/utils/common_utils_spec.js +++ b/spec/frontend/lib/utils/common_utils_spec.js @@ -987,6 +987,16 @@ describe('common_utils', () => { }); }); + describe('roundToNearestHalf', () => { + it('Rounds decimals ot the nearest half', () => { + expect(commonUtils.roundToNearestHalf(3.141592)).toBe(3); + expect(commonUtils.roundToNearestHalf(3.41592)).toBe(3.5); + expect(commonUtils.roundToNearestHalf(1.27)).toBe(1.5); + expect(commonUtils.roundToNearestHalf(1.23)).toBe(1); + expect(commonUtils.roundToNearestHalf(1.778)).toBe(2); + }); + }); + describe('searchBy', () => { const searchSpace = { iid: 1, diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js index 2df0cb00f9a..6180cd8e94d 100644 --- a/spec/frontend/lib/utils/datetime_utility_spec.js +++ b/spec/frontend/lib/utils/datetime_utility_spec.js @@ -178,6 +178,30 @@ describe('timeIntervalInWords', () => { }); }); +describe('humanizeTimeInterval', () => { + it.each` + intervalInSeconds | expected + ${0} | ${'0 seconds'} + ${1} | ${'1 second'} + ${1.48} | ${'1.5 seconds'} + ${2} | ${'2 seconds'} + ${60} | ${'1 minute'} + ${91} | ${'1.5 minutes'} + ${120} | ${'2 minutes'} + ${3600} | ${'1 hour'} + ${5401} | ${'1.5 hours'} + ${7200} | ${'2 hours'} + ${86400} | ${'1 day'} + ${129601} | ${'1.5 days'} + ${172800} | ${'2 days'} + `( + 'returns "$expected" when the time interval is $intervalInSeconds seconds', + ({ intervalInSeconds, expected }) => { + expect(datetimeUtility.humanizeTimeInterval(intervalInSeconds)).toBe(expected); + }, + ); +}); + describe('dateInWords', () => { const date = new Date('07/01/2016'); @@ -966,62 +990,6 @@ describe('format24HourTimeStringFromInt', () => { }); }); -describe('getOverlapDateInPeriods', () => { - const start = new Date(2021, 0, 11); - const end = new Date(2021, 0, 13); - - describe('when date periods overlap', () => { - const givenPeriodLeft = new Date(2021, 0, 11); - const givenPeriodRight = new Date(2021, 0, 14); - - it('returns an overlap object that contains the amount of days overlapping, the amount of hours overlapping, start date of overlap and end date of overlap', () => { - expect( - datetimeUtility.getOverlapDateInPeriods( - { start, end }, - { start: givenPeriodLeft, end: givenPeriodRight }, - ), - ).toEqual({ - daysOverlap: 2, - hoursOverlap: 48, - overlapStartDate: givenPeriodLeft.getTime(), - overlapEndDate: end.getTime(), - }); - }); - }); - - describe('when date periods do not overlap', () => { - const givenPeriodLeft = new Date(2021, 0, 9); - const givenPeriodRight = new Date(2021, 0, 10); - - it('returns an overlap object that contains a 0 value for days overlapping', () => { - expect( - datetimeUtility.getOverlapDateInPeriods( - { start, end }, - { start: givenPeriodLeft, end: givenPeriodRight }, - ), - ).toEqual({ daysOverlap: 0 }); - }); - }); - - describe('when date periods contain an invalid Date', () => { - const startInvalid = new Date(NaN); - const endInvalid = new Date(NaN); - const error = __('Invalid period'); - - it('throws an exception when the left period contains an invalid date', () => { - expect(() => - datetimeUtility.getOverlapDateInPeriods({ start, end }, { start: startInvalid, end }), - ).toThrow(error); - }); - - it('throws an exception when the right period contains an invalid date', () => { - expect(() => - datetimeUtility.getOverlapDateInPeriods({ start, end }, { start, end: endInvalid }), - ).toThrow(error); - }); - }); -}); - describe('isToday', () => { const today = new Date(); it.each` diff --git a/spec/frontend/lib/utils/forms_spec.js b/spec/frontend/lib/utils/forms_spec.js index f65bd8ffe0c..123d36ac5d5 100644 --- a/spec/frontend/lib/utils/forms_spec.js +++ b/spec/frontend/lib/utils/forms_spec.js @@ -1,4 +1,9 @@ -import { serializeForm, serializeFormObject, isEmptyValue } from '~/lib/utils/forms'; +import { + serializeForm, + serializeFormObject, + isEmptyValue, + parseRailsFormFields, +} from '~/lib/utils/forms'; describe('lib/utils/forms', () => { const createDummyForm = (inputs) => { @@ -135,4 +140,160 @@ describe('lib/utils/forms', () => { }); }); }); + + describe('parseRailsFormFields', () => { + let mountEl; + + beforeEach(() => { + mountEl = document.createElement('div'); + mountEl.classList.add('js-foo-bar'); + }); + + afterEach(() => { + mountEl = null; + }); + + it('parses fields generated by Rails and returns object with HTML attributes', () => { + mountEl.innerHTML = ` + <input type="text" placeholder="Name" value="Administrator" name="user[name]" id="user_name" data-js-name="name"> + <input type="text" placeholder="Email" value="foo@bar.com" name="user[contact_info][email]" id="user_contact_info_email" data-js-name="contactInfoEmail"> + <input type="text" placeholder="Phone" value="(123) 456-7890" name="user[contact_info][phone]" id="user_contact_info_phone" data-js-name="contact_info_phone"> + <input type="hidden" placeholder="Job title" value="" name="user[job_title]" id="user_job_title" data-js-name="jobTitle"> + <textarea name="user[bio]" id="user_bio" data-js-name="bio">Foo bar</textarea> + <select name="user[timezone]" id="user_timezone" data-js-name="timezone"> + <option value="utc+12">[UTC - 12] International Date Line West</option> + <option value="utc+11" selected>[UTC - 11] American Samoa</option> + </select> + <input type="checkbox" name="user[interests][]" id="user_interests_vue" value="Vue" checked data-js-name="interests"> + <input type="checkbox" name="user[interests][]" id="user_interests_graphql" value="GraphQL" data-js-name="interests"> + <input type="radio" name="user[access_level]" value="regular" id="user_access_level_regular" data-js-name="accessLevel"> + <input type="radio" name="user[access_level]" value="admin" id="user_access_level_admin" checked data-js-name="access_level"> + <input name="user[private_profile]" type="hidden" value="0"> + <input type="radio" name="user[private_profile]" id="user_private_profile" value="1" checked data-js-name="privateProfile"> + <input name="user[email_notifications]" type="hidden" value="0"> + <input type="radio" name="user[email_notifications]" id="user_email_notifications" value="1" data-js-name="emailNotifications"> + `; + + expect(parseRailsFormFields(mountEl)).toEqual({ + name: { + name: 'user[name]', + id: 'user_name', + value: 'Administrator', + placeholder: 'Name', + }, + contactInfoEmail: { + name: 'user[contact_info][email]', + id: 'user_contact_info_email', + value: 'foo@bar.com', + placeholder: 'Email', + }, + contactInfoPhone: { + name: 'user[contact_info][phone]', + id: 'user_contact_info_phone', + value: '(123) 456-7890', + placeholder: 'Phone', + }, + jobTitle: { + name: 'user[job_title]', + id: 'user_job_title', + value: '', + placeholder: 'Job title', + }, + bio: { + name: 'user[bio]', + id: 'user_bio', + value: 'Foo bar', + }, + timezone: { + name: 'user[timezone]', + id: 'user_timezone', + value: 'utc+11', + }, + interests: [ + { + name: 'user[interests][]', + id: 'user_interests_vue', + value: 'Vue', + checked: true, + }, + { + name: 'user[interests][]', + id: 'user_interests_graphql', + value: 'GraphQL', + checked: false, + }, + ], + accessLevel: [ + { + name: 'user[access_level]', + id: 'user_access_level_regular', + value: 'regular', + checked: false, + }, + { + name: 'user[access_level]', + id: 'user_access_level_admin', + value: 'admin', + checked: true, + }, + ], + privateProfile: [ + { + name: 'user[private_profile]', + id: 'user_private_profile', + value: '1', + checked: true, + }, + ], + emailNotifications: [ + { + name: 'user[email_notifications]', + id: 'user_email_notifications', + value: '1', + checked: false, + }, + ], + }); + }); + + it('returns an empty object if there are no inputs', () => { + expect(parseRailsFormFields(mountEl)).toEqual({}); + }); + + it('returns an empty object if inputs do not have `name` attributes', () => { + mountEl.innerHTML = ` + <input type="text" placeholder="Name" value="Administrator" id="user_name"> + <input type="text" placeholder="Email" value="foo@bar.com" id="user_contact_info_email"> + <input type="text" placeholder="Phone" value="(123) 456-7890" id="user_contact_info_phone"> + `; + + expect(parseRailsFormFields(mountEl)).toEqual({}); + }); + + it('does not include field if `data-js-name` attribute is missing', () => { + mountEl.innerHTML = ` + <input type="text" placeholder="Name" value="Administrator" name="user[name]" id="user_name" data-js-name="name"> + <input type="text" placeholder="Email" value="foo@bar.com" name="user[email]" id="email"> + `; + + expect(parseRailsFormFields(mountEl)).toEqual({ + name: { + name: 'user[name]', + id: 'user_name', + value: 'Administrator', + placeholder: 'Name', + }, + }); + }); + + it('throws error if `mountEl` argument is not passed', () => { + expect(() => parseRailsFormFields()).toThrow(new TypeError('`mountEl` argument is required')); + }); + + it('throws error if `mountEl` argument is `null`', () => { + expect(() => parseRailsFormFields(null)).toThrow( + new TypeError('`mountEl` argument is required'), + ); + }); + }); }); diff --git a/spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js index f86237dc160..f1471f625f8 100644 --- a/spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js +++ b/spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js @@ -42,6 +42,7 @@ describe('AccessRequestActionButtons', () => { memberId: member.id, title: 'Deny access', isAccessRequest: true, + isInvite: false, icon: 'close', }); }); diff --git a/spec/frontend/members/components/action_buttons/approve_access_request_button_spec.js b/spec/frontend/members/components/action_buttons/approve_access_request_button_spec.js index f77d41a642e..936715e7723 100644 --- a/spec/frontend/members/components/action_buttons/approve_access_request_button_spec.js +++ b/spec/frontend/members/components/action_buttons/approve_access_request_button_spec.js @@ -3,6 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import ApproveAccessRequestButton from '~/members/components/action_buttons/approve_access_request_button.vue'; +import { MEMBER_TYPES } from '~/members/constants'; jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); @@ -14,9 +15,14 @@ describe('ApproveAccessRequestButton', () => { const createStore = (state = {}) => { return new Vuex.Store({ - state: { - memberPath: '/groups/foo-bar/-/group_members/:id', - ...state, + modules: { + [MEMBER_TYPES.accessRequest]: { + namespaced: true, + state: { + memberPath: '/groups/foo-bar/-/group_members/:id', + ...state, + }, + }, }, }); }; @@ -25,6 +31,9 @@ describe('ApproveAccessRequestButton', () => { wrapper = shallowMount(ApproveAccessRequestButton, { localVue, store: createStore(state), + provide: { + namespace: MEMBER_TYPES.accessRequest, + }, propsData: { memberId: 1, ...propsData, diff --git a/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js index fe63f9bfaa7..e7a99a96da6 100644 --- a/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js +++ b/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js @@ -39,9 +39,11 @@ describe('InviteActionButtons', () => { it('sets props correctly', () => { expect(findRemoveMemberButton().props()).toEqual({ memberId: member.id, + memberType: null, message: `Are you sure you want to revoke the invitation for ${member.invite.email} to join "${member.source.fullName}"`, title: 'Revoke invite', isAccessRequest: false, + isInvite: true, icon: 'remove', }); }); diff --git a/spec/frontend/members/components/action_buttons/remove_group_link_button_spec.js b/spec/frontend/members/components/action_buttons/remove_group_link_button_spec.js index f6e342898cb..f91aef131a1 100644 --- a/spec/frontend/members/components/action_buttons/remove_group_link_button_spec.js +++ b/spec/frontend/members/components/action_buttons/remove_group_link_button_spec.js @@ -3,6 +3,7 @@ import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import RemoveGroupLinkButton from '~/members/components/action_buttons/remove_group_link_button.vue'; +import { MEMBER_TYPES } from '~/members/constants'; import { group } from '../../mock_data'; const localVue = createLocalVue(); @@ -17,7 +18,12 @@ describe('RemoveGroupLinkButton', () => { const createStore = () => { return new Vuex.Store({ - actions, + modules: { + [MEMBER_TYPES.group]: { + namespaced: true, + actions, + }, + }, }); }; @@ -25,6 +31,9 @@ describe('RemoveGroupLinkButton', () => { wrapper = mount(RemoveGroupLinkButton, { localVue, store: createStore(), + provide: { + namespace: MEMBER_TYPES.group, + }, propsData: { groupLink: group, }, diff --git a/spec/frontend/members/components/action_buttons/remove_member_button_spec.js b/spec/frontend/members/components/action_buttons/remove_member_button_spec.js index 437b3e705a4..4ff12f7fa97 100644 --- a/spec/frontend/members/components/action_buttons/remove_member_button_spec.js +++ b/spec/frontend/members/components/action_buttons/remove_member_button_spec.js @@ -2,6 +2,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import RemoveMemberButton from '~/members/components/action_buttons/remove_member_button.vue'; +import { MEMBER_TYPES } from '~/members/constants'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -11,9 +12,14 @@ describe('RemoveMemberButton', () => { const createStore = (state = {}) => { return new Vuex.Store({ - state: { - memberPath: '/groups/foo-bar/-/group_members/:id', - ...state, + modules: { + [MEMBER_TYPES.user]: { + namespaced: true, + state: { + memberPath: '/groups/foo-bar/-/group_members/:id', + ...state, + }, + }, }, }); }; @@ -22,11 +28,17 @@ describe('RemoveMemberButton', () => { wrapper = shallowMount(RemoveMemberButton, { localVue, store: createStore(state), + provide: { + namespace: MEMBER_TYPES.user, + }, propsData: { memberId: 1, + memberType: 'GroupMember', message: 'Are you sure you want to remove John Smith?', title: 'Remove member', isAccessRequest: true, + isInvite: true, + oncallSchedules: { name: 'user', schedules: [] }, ...propsData, }, directives: { @@ -44,8 +56,11 @@ describe('RemoveMemberButton', () => { expect(wrapper.attributes()).toMatchObject({ 'data-member-path': '/groups/foo-bar/-/group_members/1', + 'data-member-type': 'GroupMember', 'data-message': 'Are you sure you want to remove John Smith?', 'data-is-access-request': 'true', + 'data-is-invite': 'true', + 'data-oncall-schedules': '{"name":"user","schedules":[]}', 'aria-label': 'Remove member', title: 'Remove member', icon: 'remove', diff --git a/spec/frontend/members/components/action_buttons/resend_invite_button_spec.js b/spec/frontend/members/components/action_buttons/resend_invite_button_spec.js index 49b6979f954..547e067450c 100644 --- a/spec/frontend/members/components/action_buttons/resend_invite_button_spec.js +++ b/spec/frontend/members/components/action_buttons/resend_invite_button_spec.js @@ -3,6 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import ResendInviteButton from '~/members/components/action_buttons/resend_invite_button.vue'; +import { MEMBER_TYPES } from '~/members/constants'; jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); @@ -14,9 +15,14 @@ describe('ResendInviteButton', () => { const createStore = (state = {}) => { return new Vuex.Store({ - state: { - memberPath: '/groups/foo-bar/-/group_members/:id', - ...state, + modules: { + [MEMBER_TYPES.invite]: { + namespaced: true, + state: { + memberPath: '/groups/foo-bar/-/group_members/:id', + ...state, + }, + }, }, }); }; @@ -25,6 +31,9 @@ describe('ResendInviteButton', () => { wrapper = shallowMount(ResendInviteButton, { localVue, store: createStore(state), + provide: { + namespace: MEMBER_TYPES.invite, + }, propsData: { memberId: 1, ...propsData, diff --git a/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js index 1d7ea5b3109..0aa3780f030 100644 --- a/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js +++ b/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js @@ -39,10 +39,16 @@ describe('UserActionButtons', () => { it('sets props correctly', () => { expect(findRemoveMemberButton().props()).toEqual({ memberId: member.id, - message: `Are you sure you want to remove ${member.user.name} from "${member.source.fullName}"`, + memberType: 'GroupMember', + message: `Are you sure you want to remove ${member.user.name} from "${member.source.fullName}"?`, title: 'Remove member', isAccessRequest: false, + isInvite: false, icon: 'remove', + oncallSchedules: { + name: member.user.name, + schedules: member.user.oncallSchedules, + }, }); }); @@ -56,7 +62,7 @@ describe('UserActionButtons', () => { }); expect(findRemoveMemberButton().props('message')).toBe( - `Are you sure you want to remove this orphaned member from "${orphanedMember.source.fullName}"`, + `Are you sure you want to remove this orphaned member from "${orphanedMember.source.fullName}"?`, ); }); }); @@ -86,4 +92,40 @@ describe('UserActionButtons', () => { expect(findRemoveMemberButton().exists()).toBe(false); }); }); + + describe('when group member', () => { + beforeEach(() => { + createComponent({ + member: { + ...member, + type: 'GroupMember', + }, + permissions: { + canRemove: true, + }, + }); + }); + + it('sets member type correctly', () => { + expect(findRemoveMemberButton().props().memberType).toBe('GroupMember'); + }); + }); + + describe('when project member', () => { + beforeEach(() => { + createComponent({ + member: { + ...member, + type: 'ProjectMember', + }, + permissions: { + canRemove: true, + }, + }); + }); + + it('sets member type correctly', () => { + expect(findRemoveMemberButton().props().memberType).toBe('ProjectMember'); + }); + }); }); diff --git a/spec/frontend/members/components/app_spec.js b/spec/frontend/members/components/app_spec.js index a1329c3ee9f..05933e36b52 100644 --- a/spec/frontend/members/components/app_spec.js +++ b/spec/frontend/members/components/app_spec.js @@ -5,6 +5,7 @@ import Vuex from 'vuex'; import * as commonUtils from '~/lib/utils/common_utils'; import MembersApp from '~/members/components/app.vue'; import FilterSortContainer from '~/members/components/filter_sort/filter_sort_container.vue'; +import { MEMBER_TYPES } from '~/members/constants'; import { RECEIVE_MEMBER_ROLE_ERROR, HIDE_ERROR } from '~/members/store/mutation_types'; import mutations from '~/members/store/mutations'; @@ -17,16 +18,24 @@ describe('MembersApp', () => { const createComponent = (state = {}, options = {}) => { store = new Vuex.Store({ - state: { - showError: true, - errorMessage: 'Something went wrong, please try again.', - ...state, + modules: { + [MEMBER_TYPES.user]: { + namespaced: true, + state: { + showError: true, + errorMessage: 'Something went wrong, please try again.', + ...state, + }, + mutations, + }, }, - mutations, }); wrapper = shallowMount(MembersApp, { localVue, + provide: { + namespace: MEMBER_TYPES.user, + }, store, ...options, }); @@ -48,7 +57,9 @@ describe('MembersApp', () => { it('renders and scrolls to error alert', async () => { createComponent({ showError: false, errorMessage: '' }); - store.commit(RECEIVE_MEMBER_ROLE_ERROR, { error: new Error('Network Error') }); + store.commit(`${MEMBER_TYPES.user}/${RECEIVE_MEMBER_ROLE_ERROR}`, { + error: new Error('Network Error'), + }); await nextTick(); @@ -66,7 +77,7 @@ describe('MembersApp', () => { it('does not render and scroll to error alert', async () => { createComponent(); - store.commit(HIDE_ERROR); + store.commit(`${MEMBER_TYPES.user}/${HIDE_ERROR}`); await nextTick(); diff --git a/spec/frontend/members/components/avatars/user_avatar_spec.js b/spec/frontend/members/components/avatars/user_avatar_spec.js index 3f4d9155c5d..5cf3a4cdc13 100644 --- a/spec/frontend/members/components/avatars/user_avatar_spec.js +++ b/spec/frontend/members/components/avatars/user_avatar_spec.js @@ -1,31 +1,25 @@ import { GlAvatarLink, GlBadge } from '@gitlab/ui'; import { within } from '@testing-library/dom'; import { mount, createWrapper } from '@vue/test-utils'; -import Vue from 'vue'; -import Vuex from 'vuex'; import UserAvatar from '~/members/components/avatars/user_avatar.vue'; import { member as memberMock, member2faEnabled, orphanedMember } from '../../mock_data'; -Vue.use(Vuex); - describe('UserAvatar', () => { let wrapper; const { user } = memberMock; - const createComponent = (propsData = {}, state = {}) => { + const createComponent = (propsData = {}, provide = {}) => { wrapper = mount(UserAvatar, { propsData: { member: memberMock, isCurrentUser: false, ...propsData, }, - store: new Vuex.Store({ - state: { - canManageMembers: true, - ...state, - }, - }), + provide: { + canManageMembers: true, + ...provide, + }, }); }; diff --git a/spec/frontend/members/components/filter_sort/filter_sort_container_spec.js b/spec/frontend/members/components/filter_sort/filter_sort_container_spec.js index 0d9f9acbbeb..16ac52737bc 100644 --- a/spec/frontend/members/components/filter_sort/filter_sort_container_spec.js +++ b/spec/frontend/members/components/filter_sort/filter_sort_container_spec.js @@ -3,6 +3,7 @@ import Vuex from 'vuex'; import FilterSortContainer from '~/members/components/filter_sort/filter_sort_container.vue'; import MembersFilteredSearchBar from '~/members/components/filter_sort/members_filtered_search_bar.vue'; import SortDropdown from '~/members/components/filter_sort/sort_dropdown.vue'; +import { MEMBER_TYPES } from '~/members/constants'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -12,22 +13,30 @@ describe('FilterSortContainer', () => { const createComponent = (state) => { const store = new Vuex.Store({ - state: { - filteredSearchBar: { - show: true, - tokens: ['two_factor'], - searchParam: 'search', - placeholder: 'Filter members', - recentSearchesStorageKey: 'group_members', + modules: { + [MEMBER_TYPES.user]: { + namespaced: true, + state: { + filteredSearchBar: { + show: true, + tokens: ['two_factor'], + searchParam: 'search', + placeholder: 'Filter members', + recentSearchesStorageKey: 'group_members', + }, + tableSortableFields: ['account'], + ...state, + }, }, - tableSortableFields: ['account'], - ...state, }, }); wrapper = shallowMount(FilterSortContainer, { localVue, store, + provide: { + namespace: MEMBER_TYPES.user, + }, }); }; diff --git a/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js b/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js index 14b437a8c4e..af5434f7068 100644 --- a/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js +++ b/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js @@ -2,6 +2,7 @@ import { GlFilteredSearchToken } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import MembersFilteredSearchBar from '~/members/components/filter_sort/members_filtered_search_bar.vue'; +import { MEMBER_TYPES } from '~/members/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; const localVue = createLocalVue(); @@ -10,24 +11,33 @@ localVue.use(Vuex); describe('MembersFilteredSearchBar', () => { let wrapper; - const createComponent = (state) => { + const createComponent = ({ state = {}, provide = {} } = {}) => { const store = new Vuex.Store({ - state: { - sourceId: 1, - filteredSearchBar: { - show: true, - tokens: ['two_factor'], - searchParam: 'search', - placeholder: 'Filter members', - recentSearchesStorageKey: 'group_members', + modules: { + [MEMBER_TYPES.user]: { + namespaced: true, + state: { + filteredSearchBar: { + show: true, + tokens: ['two_factor'], + searchParam: 'search', + placeholder: 'Filter members', + recentSearchesStorageKey: 'group_members', + }, + ...state, + }, }, - canManageMembers: true, - ...state, }, }); wrapper = shallowMount(MembersFilteredSearchBar, { localVue, + provide: { + sourceId: 1, + canManageMembers: true, + namespace: MEMBER_TYPES.user, + ...provide, + }, store, }); }; @@ -68,14 +78,18 @@ describe('MembersFilteredSearchBar', () => { describe('when `canManageMembers` is false', () => { it('excludes 2FA token', () => { createComponent({ - filteredSearchBar: { - show: true, - tokens: ['two_factor', 'with_inherited_permissions'], - searchParam: 'search', - placeholder: 'Filter members', - recentSearchesStorageKey: 'group_members', + state: { + filteredSearchBar: { + show: true, + tokens: ['two_factor', 'with_inherited_permissions'], + searchParam: 'search', + placeholder: 'Filter members', + recentSearchesStorageKey: 'group_members', + }, + }, + provide: { + canManageMembers: false, }, - canManageMembers: false, }); expect(findFilteredSearchBar().props('tokens')).toEqual([ diff --git a/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js b/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js index 357fad741e9..4b335755980 100644 --- a/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js +++ b/spec/frontend/members/components/filter_sort/sort_dropdown_spec.js @@ -3,6 +3,7 @@ import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import * as urlUtilities from '~/lib/utils/url_utility'; import SortDropdown from '~/members/components/filter_sort/sort_dropdown.vue'; +import { MEMBER_TYPES } from '~/members/constants'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -14,22 +15,30 @@ describe('SortDropdown', () => { const createComponent = (state) => { const store = new Vuex.Store({ - state: { - sourceId: 1, - tableSortableFields: ['account', 'granted', 'expires', 'maxRole', 'lastSignIn'], - filteredSearchBar: { - show: true, - tokens: ['two_factor'], - searchParam: 'search', - placeholder: 'Filter members', - recentSearchesStorageKey: 'group_members', + modules: { + [MEMBER_TYPES.user]: { + namespaced: true, + state: { + tableSortableFields: ['account', 'granted', 'expires', 'maxRole', 'lastSignIn'], + filteredSearchBar: { + show: true, + tokens: ['two_factor'], + searchParam: 'search', + placeholder: 'Filter members', + recentSearchesStorageKey: 'group_members', + }, + ...state, + }, }, - ...state, }, }); wrapper = mount(SortDropdown, { localVue, + provide: { + sourceId: 1, + namespace: MEMBER_TYPES.user, + }, store, }); }; diff --git a/spec/frontend/members/components/modals/leave_modal_spec.js b/spec/frontend/members/components/modals/leave_modal_spec.js index 2d52911572f..ea9eb7bf923 100644 --- a/spec/frontend/members/components/modals/leave_modal_spec.js +++ b/spec/frontend/members/components/modals/leave_modal_spec.js @@ -1,10 +1,12 @@ import { GlModal, GlForm } from '@gitlab/ui'; import { within } from '@testing-library/dom'; import { mount, createLocalVue, createWrapper } from '@vue/test-utils'; +import { cloneDeep } from 'lodash'; import { nextTick } from 'vue'; import Vuex from 'vuex'; import LeaveModal from '~/members/components/modals/leave_modal.vue'; -import { LEAVE_MODAL_ID } from '~/members/constants'; +import { LEAVE_MODAL_ID, MEMBER_TYPES } from '~/members/constants'; +import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; import { member } from '../../mock_data'; jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); @@ -17,9 +19,14 @@ describe('LeaveModal', () => { const createStore = (state = {}) => { return new Vuex.Store({ - state: { - memberPath: '/groups/foo-bar/-/group_members/:id', - ...state, + modules: { + [MEMBER_TYPES.user]: { + namespaced: true, + state: { + memberPath: '/groups/foo-bar/-/group_members/:id', + ...state, + }, + }, }, }); }; @@ -28,6 +35,9 @@ describe('LeaveModal', () => { wrapper = mount(LeaveModal, { localVue, store: createStore(state), + provide: { + namespace: MEMBER_TYPES.user, + }, propsData: { member, ...propsData, @@ -39,9 +49,9 @@ describe('LeaveModal', () => { }); }; - const findModal = () => wrapper.find(GlModal); - - const findForm = () => findModal().find(GlForm); + const findModal = () => wrapper.findComponent(GlModal); + const findForm = () => findModal().findComponent(GlForm); + const findOncallSchedulesList = () => findModal().findComponent(OncallSchedulesList); const getByText = (text, options) => createWrapper(within(findModal().element).getByText(text, options)); @@ -79,6 +89,24 @@ describe('LeaveModal', () => { ); }); + describe('On-call schedules list', () => { + it("displays oncall schedules list when member's user is part of on-call schedules ", () => { + const schedulesList = findOncallSchedulesList(); + expect(schedulesList.exists()).toBe(true); + expect(schedulesList.props()).toMatchObject({ + isCurrentUser: true, + schedules: member.user.oncallSchedules, + }); + }); + + it("does NOT display oncall schedules list when member's user is NOT a part of on-call schedules ", () => { + const memberWithoutOncallSchedules = cloneDeep(member); + delete (memberWithoutOncallSchedules, 'user.oncallSchedules'); + createComponent({ member: memberWithoutOncallSchedules }); + expect(findOncallSchedulesList().exists()).toBe(false); + }); + }); + it('submits the form when "Leave" button is clicked', () => { const submitSpy = jest.spyOn(findForm().element, 'submit'); diff --git a/spec/frontend/members/components/modals/remove_group_link_modal_spec.js b/spec/frontend/members/components/modals/remove_group_link_modal_spec.js index 62df912c1a2..01279581c55 100644 --- a/spec/frontend/members/components/modals/remove_group_link_modal_spec.js +++ b/spec/frontend/members/components/modals/remove_group_link_modal_spec.js @@ -4,7 +4,7 @@ import { mount, createLocalVue, createWrapper } from '@vue/test-utils'; import { nextTick } from 'vue'; import Vuex from 'vuex'; import RemoveGroupLinkModal from '~/members/components/modals/remove_group_link_modal.vue'; -import { REMOVE_GROUP_LINK_MODAL_ID } from '~/members/constants'; +import { REMOVE_GROUP_LINK_MODAL_ID, MEMBER_TYPES } from '~/members/constants'; import { group } from '../../mock_data'; jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); @@ -21,13 +21,18 @@ describe('RemoveGroupLinkModal', () => { const createStore = (state = {}) => { return new Vuex.Store({ - state: { - memberPath: '/groups/foo-bar/-/group_links/:id', - groupLinkToRemove: group, - removeGroupLinkModalVisible: true, - ...state, + modules: { + [MEMBER_TYPES.group]: { + namespaced: true, + state: { + memberPath: '/groups/foo-bar/-/group_links/:id', + groupLinkToRemove: group, + removeGroupLinkModalVisible: true, + ...state, + }, + actions, + }, }, - actions, }); }; @@ -35,6 +40,9 @@ describe('RemoveGroupLinkModal', () => { wrapper = mount(RemoveGroupLinkModal, { localVue, store: createStore(state), + provide: { + namespace: MEMBER_TYPES.group, + }, attrs: { static: true, }, diff --git a/spec/frontend/members/components/table/expiration_datepicker_spec.js b/spec/frontend/members/components/table/expiration_datepicker_spec.js index d26172b4ed1..3c4a9ba37ff 100644 --- a/spec/frontend/members/components/table/expiration_datepicker_spec.js +++ b/spec/frontend/members/components/table/expiration_datepicker_spec.js @@ -5,6 +5,7 @@ import Vuex from 'vuex'; import { useFakeDate } from 'helpers/fake_date'; import waitForPromises from 'helpers/wait_for_promises'; import ExpirationDatepicker from '~/members/components/table/expiration_datepicker.vue'; +import { MEMBER_TYPES } from '~/members/constants'; import { member } from '../../mock_data'; const localVue = createLocalVue(); @@ -31,7 +32,11 @@ describe('ExpirationDatepicker', () => { ), }; - return new Vuex.Store({ actions }); + return new Vuex.Store({ + modules: { + [MEMBER_TYPES.user]: { namespaced: true, actions }, + }, + }); }; const createComponent = (propsData = {}) => { @@ -41,6 +46,9 @@ describe('ExpirationDatepicker', () => { permissions: { canUpdate: true }, ...propsData, }, + provide: { + namespace: MEMBER_TYPES.user, + }, localVue, store: createStore(), mocks: { diff --git a/spec/frontend/members/components/table/members_table_cell_spec.js b/spec/frontend/members/components/table/members_table_cell_spec.js index b7dcd2a9fae..5375ee11736 100644 --- a/spec/frontend/members/components/table/members_table_cell_spec.js +++ b/spec/frontend/members/components/table/members_table_cell_spec.js @@ -42,21 +42,21 @@ describe('MembersTableCell', () => { const createStore = (state = {}) => { return new Vuex.Store({ - state: { - sourceId: 1, - currentUserId: 1, - ...state, - }, + state, }); }; let wrapper; - const createComponent = (propsData, state = {}) => { + const createComponent = (propsData, state) => { wrapper = mount(MembersTableCell, { localVue, propsData, store: createStore(state), + provide: { + sourceId: 1, + currentUserId: 1, + }, scopedSlots: { default: ` <wrapped-component diff --git a/spec/frontend/members/components/table/members_table_spec.js b/spec/frontend/members/components/table/members_table_spec.js index cf5811e72e7..5cf1f40a8f4 100644 --- a/spec/frontend/members/components/table/members_table_spec.js +++ b/spec/frontend/members/components/table/members_table_spec.js @@ -14,6 +14,7 @@ import MemberAvatar from '~/members/components/table/member_avatar.vue'; import MemberSource from '~/members/components/table/member_source.vue'; import MembersTable from '~/members/components/table/members_table.vue'; import RoleDropdown from '~/members/components/table/role_dropdown.vue'; +import { MEMBER_TYPES } from '~/members/constants'; import * as initUserPopovers from '~/user_popovers'; import { member as memberMock, directMember, invite, accessRequest } from '../../mock_data'; @@ -25,24 +26,33 @@ describe('MembersTable', () => { const createStore = (state = {}) => { return new Vuex.Store({ - state: { - members: [], - tableFields: [], - tableAttrs: { - table: { 'data-qa-selector': 'members_list' }, - tr: { 'data-qa-selector': 'member_row' }, + modules: { + [MEMBER_TYPES.user]: { + namespaced: true, + state: { + members: [], + tableFields: [], + tableAttrs: { + table: { 'data-qa-selector': 'members_list' }, + tr: { 'data-qa-selector': 'member_row' }, + }, + ...state, + }, }, - sourceId: 1, - currentUserId: 1, - ...state, }, }); }; - const createComponent = (state) => { + const createComponent = (state, provide = {}) => { wrapper = mount(MembersTable, { localVue, store: createStore(state), + provide: { + sourceId: 1, + currentUserId: 1, + namespace: MEMBER_TYPES.user, + ...provide, + }, stubs: [ 'member-avatar', 'member-source', @@ -119,7 +129,7 @@ describe('MembersTable', () => { describe('when user is not logged in', () => { it('does not render the "Actions" field', () => { - createComponent({ currentUserId: null, tableFields: ['actions'] }); + createComponent({ tableFields: ['actions'] }, { currentUserId: null }); expect(within(wrapper.element).queryByTestId('col-actions')).toBe(null); }); diff --git a/spec/frontend/members/components/table/role_dropdown_spec.js b/spec/frontend/members/components/table/role_dropdown_spec.js index aa280599061..c8b6bead450 100644 --- a/spec/frontend/members/components/table/role_dropdown_spec.js +++ b/spec/frontend/members/components/table/role_dropdown_spec.js @@ -7,6 +7,7 @@ import Vuex from 'vuex'; import waitForPromises from 'helpers/wait_for_promises'; import { BV_DROPDOWN_SHOW } from '~/lib/utils/constants'; import RoleDropdown from '~/members/components/table/role_dropdown.vue'; +import { MEMBER_TYPES } from '~/members/constants'; import { member } from '../../mock_data'; const localVue = createLocalVue(); @@ -24,11 +25,18 @@ describe('RoleDropdown', () => { updateMemberRole: jest.fn(() => Promise.resolve()), }; - return new Vuex.Store({ actions }); + return new Vuex.Store({ + modules: { + [MEMBER_TYPES.user]: { namespaced: true, actions }, + }, + }); }; const createComponent = (propsData = {}) => { wrapper = mount(RoleDropdown, { + provide: { + namespace: MEMBER_TYPES.user, + }, propsData: { member, permissions: {}, diff --git a/spec/frontend/members/index_spec.js b/spec/frontend/members/index_spec.js index dd3b9ddd912..8b645d9b059 100644 --- a/spec/frontend/members/index_spec.js +++ b/spec/frontend/members/index_spec.js @@ -1,5 +1,6 @@ import { createWrapper } from '@vue/test-utils'; import MembersApp from '~/members/components/app.vue'; +import { MEMBER_TYPES } from '~/members/constants'; import { initMembersApp } from '~/members/index'; import { membersJsonString, members } from './mock_data'; @@ -10,6 +11,7 @@ describe('initMembersApp', () => { const setup = () => { vm = initMembersApp(el, { + namespace: MEMBER_TYPES.user, tableFields: ['account'], tableAttrs: { table: { 'data-qa-selector': 'members_list' } }, tableSortableFields: ['account'], @@ -42,72 +44,49 @@ describe('initMembersApp', () => { expect(wrapper.find(MembersApp).exists()).toBe(true); }); - it('sets `currentUserId` in Vuex store', () => { - setup(); - - expect(vm.$store.state.currentUserId).toBe(123); - }); - - describe('when `gon.current_user_id` is not set (user is not logged in)', () => { - it('sets `currentUserId` as `null` in Vuex store', () => { - window.gon = {}; - setup(); - - expect(vm.$store.state.currentUserId).toBeNull(); - }); - }); - - it('parses and sets `data-source-id` as `sourceId` in Vuex store', () => { - setup(); - - expect(vm.$store.state.sourceId).toBe(234); - }); - - it('parses and sets `data-can-manage-members` as `canManageMembers` in Vuex store', () => { - setup(); - - expect(vm.$store.state.canManageMembers).toBe(true); - }); - it('parses and sets `members` in Vuex store', () => { setup(); - expect(vm.$store.state.members).toEqual(members); + expect(vm.$store.state[MEMBER_TYPES.user].members).toEqual(members); }); it('sets `tableFields` in Vuex store', () => { setup(); - expect(vm.$store.state.tableFields).toEqual(['account']); + expect(vm.$store.state[MEMBER_TYPES.user].tableFields).toEqual(['account']); }); it('sets `tableAttrs` in Vuex store', () => { setup(); - expect(vm.$store.state.tableAttrs).toEqual({ table: { 'data-qa-selector': 'members_list' } }); + expect(vm.$store.state[MEMBER_TYPES.user].tableAttrs).toEqual({ + table: { 'data-qa-selector': 'members_list' }, + }); }); it('sets `tableSortableFields` in Vuex store', () => { setup(); - expect(vm.$store.state.tableSortableFields).toEqual(['account']); + expect(vm.$store.state[MEMBER_TYPES.user].tableSortableFields).toEqual(['account']); }); it('sets `requestFormatter` in Vuex store', () => { setup(); - expect(vm.$store.state.requestFormatter()).toEqual({}); + expect(vm.$store.state[MEMBER_TYPES.user].requestFormatter()).toEqual({}); }); it('sets `filteredSearchBar` in Vuex store', () => { setup(); - expect(vm.$store.state.filteredSearchBar).toEqual({ show: false }); + expect(vm.$store.state[MEMBER_TYPES.user].filteredSearchBar).toEqual({ show: false }); }); it('sets `memberPath` in Vuex store', () => { setup(); - expect(vm.$store.state.memberPath).toBe('/groups/foo-bar/-/group_members/:id'); + expect(vm.$store.state[MEMBER_TYPES.user].memberPath).toBe( + '/groups/foo-bar/-/group_members/:id', + ); }); }); diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js index 6a73b2fcf8c..a47b7ab2118 100644 --- a/spec/frontend/members/mock_data.js +++ b/spec/frontend/members/mock_data.js @@ -11,6 +11,7 @@ export const member = { fullName: 'Foo Bar', webUrl: 'https://gitlab.com/groups/foo-bar', }, + type: 'GroupMember', user: { id: 123, name: 'Administrator', @@ -19,6 +20,7 @@ export const member = { avatarUrl: 'https://www.gravatar.com/avatar/4816142ef496f956a277bedf1a40607b?s=80&d=identicon', blocked: false, twoFactorEnabled: false, + oncallSchedules: [{ name: 'schedule 1' }], }, id: 238, createdAt: '2020-07-17T16:22:46.923Z', diff --git a/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js b/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js new file mode 100644 index 00000000000..eaa3b1c5d53 --- /dev/null +++ b/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js @@ -0,0 +1,131 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import InlineConflictLines from '~/merge_conflicts/components/inline_conflict_lines.vue'; +import ParallelConflictLines from '~/merge_conflicts/components/parallel_conflict_lines.vue'; +import component from '~/merge_conflicts/merge_conflict_resolver_app.vue'; +import { createStore } from '~/merge_conflicts/store'; +import { decorateFiles } from '~/merge_conflicts/utils'; +import { conflictsMock } from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Merge Conflict Resolver App', () => { + let wrapper; + let store; + + const decoratedMockFiles = decorateFiles(conflictsMock.files); + + const mountComponent = () => { + wrapper = shallowMount(component, { + store, + stubs: { GlSprintf }, + provide() { + return { + mergeRequestPath: 'foo', + sourceBranchPath: 'foo', + resolveConflictsPath: 'bar', + }; + }, + }); + }; + + beforeEach(() => { + store = createStore(); + store.commit('SET_LOADING_STATE', false); + store.dispatch('setConflictsData', conflictsMock); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findConflictsCount = () => wrapper.find('[data-testid="conflicts-count"]'); + const findFiles = () => wrapper.findAll('[data-testid="files"]'); + const findFileHeader = (w = wrapper) => w.find('[data-testid="file-name"]'); + const findFileInteractiveButton = (w = wrapper) => w.find('[data-testid="interactive-button"]'); + const findFileInlineButton = (w = wrapper) => w.find('[data-testid="inline-button"]'); + const findSideBySideButton = () => wrapper.find('[data-testid="side-by-side"]'); + const findInlineConflictLines = (w = wrapper) => w.find(InlineConflictLines); + const findParallelConflictLines = (w = wrapper) => w.find(ParallelConflictLines); + const findCommitMessageTextarea = () => wrapper.find('[data-testid="commit-message"]'); + + it('shows the amount of conflicts', () => { + mountComponent(); + + const title = findConflictsCount(); + + expect(title.exists()).toBe(true); + expect(title.text().trim()).toBe('Showing 3 conflicts between test-conflicts and master'); + }); + + describe('files', () => { + it('shows one file area for each file', () => { + mountComponent(); + + expect(findFiles()).toHaveLength(conflictsMock.files.length); + }); + + it('has the appropriate file header', () => { + mountComponent(); + + const fileHeader = findFileHeader(findFiles().at(0)); + + expect(fileHeader.text()).toBe(decoratedMockFiles[0].filePath); + }); + + describe('editing', () => { + it('interactive mode is the default', () => { + mountComponent(); + + const interactiveButton = findFileInteractiveButton(findFiles().at(0)); + const inlineButton = findFileInlineButton(findFiles().at(0)); + + expect(interactiveButton.classes('active')).toBe(true); + expect(inlineButton.classes('active')).toBe(false); + }); + + it('clicking inline set inline as default', async () => { + mountComponent(); + + const inlineButton = findFileInlineButton(findFiles().at(0)); + expect(inlineButton.classes('active')).toBe(false); + + inlineButton.trigger('click'); + await wrapper.vm.$nextTick(); + + expect(inlineButton.classes('active')).toBe(true); + }); + + it('inline mode shows a inline-conflict-lines', () => { + mountComponent(); + + const inlineConflictLinesComponent = findInlineConflictLines(findFiles().at(0)); + + expect(inlineConflictLinesComponent.exists()).toBe(true); + expect(inlineConflictLinesComponent.props('file')).toEqual(decoratedMockFiles[0]); + }); + + it('parallel mode shows a parallel-conflict-lines', async () => { + mountComponent(); + + findSideBySideButton().trigger('click'); + await wrapper.vm.$nextTick(); + + const parallelConflictLinesComponent = findParallelConflictLines(findFiles().at(0)); + + expect(parallelConflictLinesComponent.exists()).toBe(true); + expect(parallelConflictLinesComponent.props('file')).toEqual(decoratedMockFiles[0]); + }); + }); + }); + + describe('submit form', () => { + it('contains a commit message textarea', () => { + mountComponent(); + + expect(findCommitMessageTextarea().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/merge_conflicts/mock_data.js b/spec/frontend/merge_conflicts/mock_data.js new file mode 100644 index 00000000000..8948f2a3c1e --- /dev/null +++ b/spec/frontend/merge_conflicts/mock_data.js @@ -0,0 +1,340 @@ +export const conflictsMock = { + target_branch: 'master', + source_branch: 'test-conflicts', + commit_sha: '6dbf385a3c7bf01e09b5d2d9e5d72f8fb8c590a3', + commit_message: + "Merge branch 'master' into 'test-conflicts'\n\n# Conflicts:\n# .gitlab-ci.yml\n# README.md", + files: [ + { + old_path: '.gitlab-ci.yml', + new_path: '.gitlab-ci.yml', + blob_icon: 'doc-text', + blob_path: + '/gitlab-org/gitlab-test/-/blob/6dbf385a3c7bf01e09b5d2d9e5d72f8fb8c590a3/.gitlab-ci.yml', + sections: [ + { + conflict: false, + lines: [ + { + line_code: null, + type: 'match', + old_line: null, + new_line: null, + text: '@@ -7,10 +7,11 @@ upload:', + meta_data: { old_pos: 7, new_pos: 7 }, + rich_text: '@@ -7,10 +7,11 @@ upload:', + can_receive_suggestion: true, + }, + { + line_code: '587d266bb27a4dc3022bbed44dfa19849df3044c_7_7', + type: null, + old_line: 7, + new_line: 7, + text: ' stage: upload', + meta_data: null, + rich_text: + '\u003cspan id="LC7" class="line" lang="yaml"\u003e \u003cspan class="na"\u003estage\u003c/span\u003e\u003cspan class="pi"\u003e:\u003c/span\u003e \u003cspan class="s"\u003eupload\u003c/span\u003e\u003c/span\u003e\n', + can_receive_suggestion: true, + }, + { + line_code: '587d266bb27a4dc3022bbed44dfa19849df3044c_8_8', + type: null, + old_line: 8, + new_line: 8, + text: ' script:', + meta_data: null, + rich_text: + '\u003cspan id="LC8" class="line" lang="yaml"\u003e \u003cspan class="na"\u003escript\u003c/span\u003e\u003cspan class="pi"\u003e:\u003c/span\u003e\u003c/span\u003e\n', + can_receive_suggestion: true, + }, + { + line_code: '587d266bb27a4dc3022bbed44dfa19849df3044c_9_9', + type: null, + old_line: 9, + new_line: 9, + text: + // eslint-disable-next-line no-template-curly-in-string + ' - \'curl --header "JOB-TOKEN: $CI_JOB_TOKEN" --upload-file README.md ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/my_package/0.0.1/file.txt\'', + meta_data: null, + rich_text: + // eslint-disable-next-line no-template-curly-in-string + '\u003cspan id="LC9" class="line" lang="yaml"\u003e \u003cspan class="pi"\u003e-\u003c/span\u003e \u003cspan class="s1"\u003e\'\u003c/span\u003e\u003cspan class="s"\u003ecurl\u003c/span\u003e\u003cspan class="nv"\u003e \u003c/span\u003e\u003cspan class="s"\u003e--header\u003c/span\u003e\u003cspan class="nv"\u003e \u003c/span\u003e\u003cspan class="s"\u003e"JOB-TOKEN:\u003c/span\u003e\u003cspan class="nv"\u003e \u003c/span\u003e\u003cspan class="s"\u003e$CI_JOB_TOKEN"\u003c/span\u003e\u003cspan class="nv"\u003e \u003c/span\u003e\u003cspan class="s"\u003e--upload-file\u003c/span\u003e\u003cspan class="nv"\u003e \u003c/span\u003e\u003cspan class="s"\u003eREADME.md\u003c/span\u003e\u003cspan class="nv"\u003e \u003c/span\u003e\u003cspan class="s"\u003e${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/my_package/0.0.1/file.txt\'\u003c/span\u003e\u003c/span\u003e\n', + can_receive_suggestion: true, + }, + ], + }, + { + conflict: true, + lines: [ + { + line_code: '587d266bb27a4dc3022bbed44dfa19849df3044c_10_10', + type: 'new', + old_line: null, + new_line: 10, + text: '# some new comments', + meta_data: null, + rich_text: + '\u003cspan id="LC10" class="line" lang="yaml"\u003e\u003cspan class="c1"\u003e# some new comments\u003c/span\u003e\u003c/span\u003e', + can_receive_suggestion: true, + }, + { + line_code: '587d266bb27a4dc3022bbed44dfa19849df3044c_10_11', + type: 'old', + old_line: 10, + new_line: null, + text: '# a different comment', + meta_data: null, + rich_text: + '\u003cspan id="LC10" class="line" lang="yaml"\u003e\u003cspan class="c1"\u003e# a different comment\u003c/span\u003e\u003c/span\u003e', + can_receive_suggestion: false, + }, + ], + id: '587d266bb27a4dc3022bbed44dfa19849df3044c_10_10', + }, + ], + type: 'text', + content_path: + '/gitlab-org/gitlab-test/-/merge_requests/2/conflict_for_path?new_path=.gitlab-ci.yml\u0026old_path=.gitlab-ci.yml', + }, + { + old_path: 'README.md', + new_path: 'README.md', + blob_icon: 'doc-text', + blob_path: + '/gitlab-org/gitlab-test/-/blob/6dbf385a3c7bf01e09b5d2d9e5d72f8fb8c590a3/README.md', + sections: [ + { + conflict: false, + lines: [ + { + line_code: '8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d_1_1', + type: null, + old_line: 1, + new_line: 1, + text: '- 1', + meta_data: null, + rich_text: + '\u003cspan id="LC1" class="line" lang="markdown"\u003e\u003cspan class="p"\u003e-\u003c/span\u003e 1\u003c/span\u003e\n', + can_receive_suggestion: true, + }, + { + line_code: '8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d_2_2', + type: null, + old_line: 2, + new_line: 2, + text: '- 2', + meta_data: null, + rich_text: + '\u003cspan id="LC2" class="line" lang="markdown"\u003e\u003cspan class="p"\u003e-\u003c/span\u003e 2\u003c/span\u003e\n', + can_receive_suggestion: true, + }, + { + line_code: '8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d_3_3', + type: null, + old_line: 3, + new_line: 3, + text: '- 3', + meta_data: null, + rich_text: + '\u003cspan id="LC3" class="line" lang="markdown"\u003e\u003cspan class="p"\u003e-\u003c/span\u003e 3\u003c/span\u003e\n', + can_receive_suggestion: true, + }, + ], + }, + { + conflict: true, + lines: [ + { + line_code: '8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d_4_4', + type: 'new', + old_line: null, + new_line: 4, + text: '- 4c', + meta_data: null, + rich_text: + '\u003cspan id="LC4" class="line" lang="markdown"\u003e\u003cspan class="p"\u003e-\u003c/span\u003e 4c\u003c/span\u003e\n', + can_receive_suggestion: true, + }, + { + line_code: '8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d_4_5', + type: 'old', + old_line: 4, + new_line: null, + text: '- 4b', + meta_data: null, + rich_text: + '\u003cspan id="LC4" class="line" lang="markdown"\u003e\u003cspan class="p"\u003e-\u003c/span\u003e 4b\u003c/span\u003e\n', + can_receive_suggestion: false, + }, + ], + id: '8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d_4_4', + }, + { + conflict: false, + lines: [ + { + line_code: '8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d_5_5', + type: null, + old_line: 5, + new_line: 5, + text: '- 5', + meta_data: null, + rich_text: + '\u003cspan id="LC5" class="line" lang="markdown"\u003e\u003cspan class="p"\u003e-\u003c/span\u003e 5\u003c/span\u003e\n', + can_receive_suggestion: true, + }, + { + line_code: '8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d_6_6', + type: null, + old_line: 6, + new_line: 6, + text: '- 6', + meta_data: null, + rich_text: + '\u003cspan id="LC6" class="line" lang="markdown"\u003e\u003cspan class="p"\u003e-\u003c/span\u003e 6\u003c/span\u003e\n', + can_receive_suggestion: true, + }, + { + line_code: '8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d_7_7', + type: null, + old_line: 7, + new_line: 7, + text: '- 7', + meta_data: null, + rich_text: + '\u003cspan id="LC7" class="line" lang="markdown"\u003e\u003cspan class="p"\u003e-\u003c/span\u003e 7\u003c/span\u003e\n', + can_receive_suggestion: true, + }, + ], + }, + { + conflict: false, + lines: [ + { + line_code: null, + type: 'match', + old_line: null, + new_line: null, + text: '@@ -9,15 +9,15 @@', + meta_data: { old_pos: 9, new_pos: 9 }, + rich_text: '@@ -9,15 +9,15 @@', + can_receive_suggestion: true, + }, + { + line_code: '8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d_9_9', + type: null, + old_line: 9, + new_line: 9, + text: '- 9', + meta_data: null, + rich_text: + '\u003cspan id="LC9" class="line" lang="markdown"\u003e\u003cspan class="p"\u003e-\u003c/span\u003e 9\u003c/span\u003e\n', + can_receive_suggestion: true, + }, + { + line_code: '8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d_10_10', + type: null, + old_line: 10, + new_line: 10, + text: '- 10', + meta_data: null, + rich_text: + '\u003cspan id="LC10" class="line" lang="markdown"\u003e\u003cspan class="p"\u003e-\u003c/span\u003e 10\u003c/span\u003e\n', + can_receive_suggestion: true, + }, + { + line_code: '8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d_11_11', + type: null, + old_line: 11, + new_line: 11, + text: '- 11', + meta_data: null, + rich_text: + '\u003cspan id="LC11" class="line" lang="markdown"\u003e\u003cspan class="p"\u003e-\u003c/span\u003e 11\u003c/span\u003e\n', + can_receive_suggestion: true, + }, + ], + }, + { + conflict: true, + lines: [ + { + line_code: '8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d_12_12', + type: 'new', + old_line: null, + new_line: 12, + text: '- 12c', + meta_data: null, + rich_text: + '\u003cspan id="LC12" class="line" lang="markdown"\u003e\u003cspan class="p"\u003e-\u003c/span\u003e 12c\u003c/span\u003e\n', + can_receive_suggestion: true, + }, + { + line_code: '8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d_12_13', + type: 'old', + old_line: 12, + new_line: null, + text: '- 12b', + meta_data: null, + rich_text: + '\u003cspan id="LC12" class="line" lang="markdown"\u003e\u003cspan class="p"\u003e-\u003c/span\u003e 12b\u003c/span\u003e\n', + can_receive_suggestion: false, + }, + ], + id: '8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d_12_12', + }, + { + conflict: false, + lines: [ + { + line_code: '8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d_13_13', + type: null, + old_line: 13, + new_line: 13, + text: '- 13', + meta_data: null, + rich_text: + '\u003cspan id="LC13" class="line" lang="markdown"\u003e\u003cspan class="p"\u003e-\u003c/span\u003e 13\u003c/span\u003e\n', + can_receive_suggestion: true, + }, + { + line_code: '8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d_14_14', + type: null, + old_line: 14, + new_line: 14, + text: '- 14 ', + meta_data: null, + rich_text: + '\u003cspan id="LC14" class="line" lang="markdown"\u003e\u003cspan class="p"\u003e-\u003c/span\u003e 14 \u003c/span\u003e\n', + can_receive_suggestion: true, + }, + { + line_code: '8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d_15_15', + type: null, + old_line: 15, + new_line: 15, + text: '- 15', + meta_data: null, + rich_text: + '\u003cspan id="LC15" class="line" lang="markdown"\u003e\u003cspan class="p"\u003e-\u003c/span\u003e 15\u003c/span\u003e\n', + can_receive_suggestion: true, + }, + { + line_code: null, + type: 'match', + old_line: null, + new_line: null, + text: '', + meta_data: { old_pos: 15, new_pos: 15 }, + rich_text: '', + can_receive_suggestion: true, + }, + ], + }, + ], + type: 'text', + content_path: + '/gitlab-org/gitlab-test/-/merge_requests/2/conflict_for_path?new_path=README.md\u0026old_path=README.md', + }, + ], +}; diff --git a/spec/frontend/merge_conflicts/store/actions_spec.js b/spec/frontend/merge_conflicts/store/actions_spec.js index 352f1783b87..8fa8765a9f9 100644 --- a/spec/frontend/merge_conflicts/store/actions_spec.js +++ b/spec/frontend/merge_conflicts/store/actions_spec.js @@ -1,5 +1,6 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import Cookies from 'js-cookie'; import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import testAction from 'helpers/vuex_action_helper'; import createFlash from '~/flash'; @@ -10,6 +11,7 @@ import { restoreFileLinesState, markLine, decorateFiles } from '~/merge_conflict jest.mock('~/flash.js'); jest.mock('~/merge_conflicts/utils'); +jest.mock('js-cookie'); describe('merge conflicts actions', () => { let mock; @@ -80,6 +82,25 @@ describe('merge conflicts actions', () => { }); }); + describe('setConflictsData', () => { + it('INTERACTIVE_RESOLVE_MODE updates the correct file ', (done) => { + decorateFiles.mockReturnValue([{ bar: 'baz' }]); + testAction( + actions.setConflictsData, + { files, foo: 'bar' }, + {}, + [ + { + type: types.SET_CONFLICTS_DATA, + payload: { foo: 'bar', files: [{ bar: 'baz' }] }, + }, + ], + [], + done, + ); + }); + }); + describe('submitResolvedConflicts', () => { useMockLocationHelper(); const resolveConflictsPath = 'resolve/conflicts/path/mock'; @@ -120,21 +141,109 @@ describe('merge conflicts actions', () => { }); }); - describe('setConflictsData', () => { - it('INTERACTIVE_RESOLVE_MODE updates the correct file ', (done) => { - decorateFiles.mockReturnValue([{ bar: 'baz' }]); + describe('setLoadingState', () => { + it('commits the right mutation', () => { testAction( - actions.setConflictsData, - { files, foo: 'bar' }, + actions.setLoadingState, + true, {}, [ { - type: types.SET_CONFLICTS_DATA, - payload: { foo: 'bar', files: [{ bar: 'baz' }] }, + type: types.SET_LOADING_STATE, + payload: true, + }, + ], + [], + ); + }); + }); + + describe('setErrorState', () => { + it('commits the right mutation', () => { + testAction( + actions.setErrorState, + true, + {}, + [ + { + type: types.SET_ERROR_STATE, + payload: true, + }, + ], + [], + ); + }); + }); + + describe('setFailedRequest', () => { + it('commits the right mutation', () => { + testAction( + actions.setFailedRequest, + 'errors in the request', + {}, + [ + { + type: types.SET_FAILED_REQUEST, + payload: 'errors in the request', + }, + ], + [], + ); + }); + }); + + describe('setViewType', () => { + it('commits the right mutation', (done) => { + const payload = 'viewType'; + testAction( + actions.setViewType, + payload, + {}, + [ + { + type: types.SET_VIEW_TYPE, + payload, + }, + ], + [], + () => { + expect(Cookies.set).toHaveBeenCalledWith('diff_view', payload); + done(); + }, + ); + }); + }); + + describe('setSubmitState', () => { + it('commits the right mutation', () => { + testAction( + actions.setSubmitState, + true, + {}, + [ + { + type: types.SET_SUBMIT_STATE, + payload: true, + }, + ], + [], + ); + }); + }); + + describe('updateCommitMessage', () => { + it('commits the right mutation', () => { + testAction( + actions.updateCommitMessage, + 'some message', + {}, + [ + { + type: types.UPDATE_CONFLICTS_DATA, + payload: { commitMessage: 'some message' }, }, ], [], - done, ); }); }); diff --git a/spec/frontend/merge_conflicts/store/getters_spec.js b/spec/frontend/merge_conflicts/store/getters_spec.js new file mode 100644 index 00000000000..7a26a2bba6a --- /dev/null +++ b/spec/frontend/merge_conflicts/store/getters_spec.js @@ -0,0 +1,187 @@ +import { + CONFLICT_TYPES, + EDIT_RESOLVE_MODE, + INTERACTIVE_RESOLVE_MODE, +} from '~/merge_conflicts/constants'; +import * as getters from '~/merge_conflicts/store/getters'; +import realState from '~/merge_conflicts/store/state'; + +describe('Merge Conflicts getters', () => { + let state; + + beforeEach(() => { + state = realState(); + }); + + describe('getConflictsCount', () => { + it('returns zero when there are no files', () => { + state.conflictsData.files = []; + + expect(getters.getConflictsCount(state)).toBe(0); + }); + + it(`counts the number of sections in files of type ${CONFLICT_TYPES.TEXT}`, () => { + state.conflictsData.files = [ + { sections: [{ conflict: true }], type: CONFLICT_TYPES.TEXT }, + { sections: [{ conflict: true }, { conflict: true }], type: CONFLICT_TYPES.TEXT }, + ]; + expect(getters.getConflictsCount(state)).toBe(3); + }); + + it(`counts the number of file in files not of type ${CONFLICT_TYPES.TEXT}`, () => { + state.conflictsData.files = [ + { sections: [{ conflict: true }], type: '' }, + { sections: [{ conflict: true }, { conflict: true }], type: '' }, + ]; + expect(getters.getConflictsCount(state)).toBe(2); + }); + }); + + describe('getConflictsCountText', () => { + it('with one conflicts', () => { + const getConflictsCount = 1; + + expect(getters.getConflictsCountText(state, { getConflictsCount })).toBe('1 conflict'); + }); + + it('with more than one conflicts', () => { + const getConflictsCount = 3; + + expect(getters.getConflictsCountText(state, { getConflictsCount })).toBe('3 conflicts'); + }); + }); + + describe('isReadyToCommit', () => { + it('return false when isSubmitting is true', () => { + state.conflictsData.files = []; + state.isSubmitting = true; + state.conflictsData.commitMessage = 'foo'; + + expect(getters.isReadyToCommit(state)).toBe(false); + }); + + it('returns false when has no commit message', () => { + state.conflictsData.files = []; + state.isSubmitting = false; + state.conflictsData.commitMessage = ''; + + expect(getters.isReadyToCommit(state)).toBe(false); + }); + + it('returns true when all conflicts are resolved and is not submitting and we have a commitMessage', () => { + state.conflictsData.files = [ + { + resolveMode: INTERACTIVE_RESOLVE_MODE, + type: CONFLICT_TYPES.TEXT, + sections: [{ conflict: true }], + resolutionData: { foo: 'bar' }, + }, + ]; + state.isSubmitting = false; + state.conflictsData.commitMessage = 'foo'; + + expect(getters.isReadyToCommit(state)).toBe(true); + }); + + describe('unresolved', () => { + it(`files with resolvedMode set to ${EDIT_RESOLVE_MODE} and empty count as unresolved`, () => { + state.conflictsData.files = [ + { content: '', resolveMode: EDIT_RESOLVE_MODE }, + { content: 'foo' }, + ]; + state.isSubmitting = false; + state.conflictsData.commitMessage = 'foo'; + + expect(getters.isReadyToCommit(state)).toBe(false); + }); + + it(`in files with resolvedMode = ${INTERACTIVE_RESOLVE_MODE} we count resolvedConflicts vs unresolved ones`, () => { + state.conflictsData.files = [ + { + resolveMode: INTERACTIVE_RESOLVE_MODE, + type: CONFLICT_TYPES.TEXT, + sections: [{ conflict: true }], + resolutionData: {}, + }, + ]; + state.isSubmitting = false; + state.conflictsData.commitMessage = 'foo'; + + expect(getters.isReadyToCommit(state)).toBe(false); + }); + }); + }); + + describe('getCommitButtonText', () => { + it('when is submitting', () => { + state.isSubmitting = true; + expect(getters.getCommitButtonText(state)).toBe('Committing...'); + }); + + it('when is not submitting', () => { + expect(getters.getCommitButtonText(state)).toBe('Commit to source branch'); + }); + }); + + describe('getCommitData', () => { + it('returns commit data', () => { + const baseFile = { + new_path: 'new_path', + old_path: 'new_path', + }; + + state.conflictsData.commitMessage = 'foo'; + state.conflictsData.files = [ + { + ...baseFile, + resolveMode: INTERACTIVE_RESOLVE_MODE, + type: CONFLICT_TYPES.TEXT, + sections: [{ conflict: true }], + resolutionData: { bar: 'baz' }, + }, + { + ...baseFile, + resolveMode: EDIT_RESOLVE_MODE, + type: CONFLICT_TYPES.TEXT, + content: 'resolve_mode_content', + }, + { + ...baseFile, + type: CONFLICT_TYPES.TEXT_EDITOR, + content: 'text_editor_content', + }, + ]; + + expect(getters.getCommitData(state)).toStrictEqual({ + commit_message: 'foo', + files: [ + { ...baseFile, sections: { bar: 'baz' } }, + { ...baseFile, content: 'resolve_mode_content' }, + { ...baseFile, content: 'text_editor_content' }, + ], + }); + }); + }); + + describe('fileTextTypePresent', () => { + it(`returns true if there is a file with type ${CONFLICT_TYPES.TEXT}`, () => { + state.conflictsData.files = [{ type: CONFLICT_TYPES.TEXT }]; + + expect(getters.fileTextTypePresent(state)).toBe(true); + }); + it(`returns false if there is no file with type ${CONFLICT_TYPES.TEXT}`, () => { + state.conflictsData.files = [{ type: CONFLICT_TYPES.TEXT_EDITOR }]; + + expect(getters.fileTextTypePresent(state)).toBe(false); + }); + }); + + describe('getFileIndex', () => { + it(`returns the index of a file from it's blob path`, () => { + const blobPath = 'blobPath/foo'; + state.conflictsData.files = [{ foo: 'bar' }, { baz: 'foo', blobPath }]; + + expect(getters.getFileIndex(state)({ blobPath })).toBe(1); + }); + }); +}); diff --git a/spec/frontend/merge_conflicts/store/mutations_spec.js b/spec/frontend/merge_conflicts/store/mutations_spec.js new file mode 100644 index 00000000000..1476f0c5369 --- /dev/null +++ b/spec/frontend/merge_conflicts/store/mutations_spec.js @@ -0,0 +1,99 @@ +import { VIEW_TYPES } from '~/merge_conflicts/constants'; +import * as types from '~/merge_conflicts/store/mutation_types'; +import mutations from '~/merge_conflicts/store/mutations'; +import realState from '~/merge_conflicts/store/state'; + +describe('Mutations merge conflicts store', () => { + let mockState; + + beforeEach(() => { + mockState = realState(); + }); + + describe('SET_LOADING_STATE', () => { + it('should set loading', () => { + mutations[types.SET_LOADING_STATE](mockState, true); + + expect(mockState.isLoading).toBe(true); + }); + }); + + describe('SET_ERROR_STATE', () => { + it('should set hasError', () => { + mutations[types.SET_ERROR_STATE](mockState, true); + + expect(mockState.hasError).toBe(true); + }); + }); + + describe('SET_FAILED_REQUEST', () => { + it('should set hasError and errorMessage', () => { + const payload = 'message'; + mutations[types.SET_FAILED_REQUEST](mockState, payload); + + expect(mockState.hasError).toBe(true); + expect(mockState.conflictsData.errorMessage).toBe(payload); + }); + }); + + describe('SET_VIEW_TYPE', () => { + it('should set diffView', () => { + mutations[types.SET_VIEW_TYPE](mockState, VIEW_TYPES.INLINE); + + expect(mockState.diffView).toBe(VIEW_TYPES.INLINE); + }); + + it(`if payload is ${VIEW_TYPES.PARALLEL} sets isParallel`, () => { + mutations[types.SET_VIEW_TYPE](mockState, VIEW_TYPES.PARALLEL); + + expect(mockState.isParallel).toBe(true); + }); + }); + + describe('SET_SUBMIT_STATE', () => { + it('should set isSubmitting', () => { + mutations[types.SET_SUBMIT_STATE](mockState, true); + + expect(mockState.isSubmitting).toBe(true); + }); + }); + + describe('SET_CONFLICTS_DATA', () => { + it('should set conflictsData', () => { + mutations[types.SET_CONFLICTS_DATA](mockState, { + files: [], + commit_message: 'foo', + source_branch: 'bar', + target_branch: 'baz', + commit_sha: '123456789', + }); + + expect(mockState.conflictsData).toStrictEqual({ + files: [], + commitMessage: 'foo', + sourceBranch: 'bar', + targetBranch: 'baz', + shortCommitSha: '1234567', + }); + }); + }); + + describe('UPDATE_CONFLICTS_DATA', () => { + it('should update existing conflicts data', () => { + const payload = { foo: 'bar' }; + mutations[types.UPDATE_CONFLICTS_DATA](mockState, payload); + + expect(mockState.conflictsData).toStrictEqual(payload); + }); + }); + + describe('UPDATE_FILE', () => { + it('should update a file based on its index', () => { + mockState.conflictsData.files = [{ foo: 'bar' }, { baz: 'bar' }]; + + mutations[types.UPDATE_FILE](mockState, { file: { new: 'one' }, index: 1 }); + + expect(mockState.conflictsData.files).toStrictEqual([{ foo: 'bar' }, { new: 'one' }]); + }); + }); +}); diff --git a/spec/frontend/merge_conflicts/utils_spec.js b/spec/frontend/merge_conflicts/utils_spec.js new file mode 100644 index 00000000000..5bf7ecf8cfe --- /dev/null +++ b/spec/frontend/merge_conflicts/utils_spec.js @@ -0,0 +1,106 @@ +import * as utils from '~/merge_conflicts/utils'; + +describe('merge conflicts utils', () => { + describe('getFilePath', () => { + it('returns new path if they are the same', () => { + expect(utils.getFilePath({ new_path: 'a', old_path: 'a' })).toBe('a'); + }); + + it('returns concatenated paths if they are different', () => { + expect(utils.getFilePath({ new_path: 'b', old_path: 'a' })).toBe('a → b'); + }); + }); + + describe('checkLineLengths', () => { + it('add empty lines to the left when right has more lines', () => { + const result = utils.checkLineLengths({ left: [1], right: [1, 2] }); + + expect(result.left).toHaveLength(result.right.length); + expect(result.left).toStrictEqual([1, { lineType: 'emptyLine', richText: '' }]); + }); + + it('add empty lines to the right when left has more lines', () => { + const result = utils.checkLineLengths({ left: [1, 2], right: [1] }); + + expect(result.right).toHaveLength(result.left.length); + expect(result.right).toStrictEqual([1, { lineType: 'emptyLine', richText: '' }]); + }); + }); + + describe('getHeadHeaderLine', () => { + it('decorates the id', () => { + expect(utils.getHeadHeaderLine(1)).toStrictEqual({ + buttonTitle: 'Use ours', + id: 1, + isHead: true, + isHeader: true, + isSelected: false, + isUnselected: false, + richText: 'HEAD//our changes', + section: 'head', + type: 'new', + }); + }); + }); + + describe('decorateLineForInlineView', () => { + it.each` + type | truthyProp + ${'new'} | ${'isHead'} + ${'old'} | ${'isOrigin'} + ${'match'} | ${'hasMatch'} + `( + 'when the type is $type decorates the line with $truthyProp set as true', + ({ type, truthyProp }) => { + expect(utils.decorateLineForInlineView({ type, rich_text: 'rich' }, 1, true)).toStrictEqual( + { + id: 1, + hasConflict: true, + isHead: false, + isOrigin: false, + hasMatch: false, + richText: 'rich', + isSelected: false, + isUnselected: false, + [truthyProp]: true, + }, + ); + }, + ); + }); + + describe('getLineForParallelView', () => { + it.todo('should return a proper value'); + }); + + describe('getOriginHeaderLine', () => { + it('decorates the id', () => { + expect(utils.getOriginHeaderLine(1)).toStrictEqual({ + buttonTitle: 'Use theirs', + id: 1, + isHeader: true, + isOrigin: true, + isSelected: false, + isUnselected: false, + richText: 'origin//their changes', + section: 'origin', + type: 'old', + }); + }); + }); + describe('setInlineLine', () => { + it.todo('should return a proper value'); + }); + describe('setParallelLine', () => { + it.todo('should return a proper value'); + }); + describe('decorateFiles', () => { + it.todo('should return a proper value'); + }); + describe('restoreFileLinesState', () => { + it.todo('should return a proper value'); + }); + describe('markLine', () => { + it.todo('should return a proper value'); + }); +}); diff --git a/spec/frontend/merge_request/components/status_box_spec.js b/spec/frontend/merge_request/components/status_box_spec.js index 9212ae19c2d..de0f3574ab2 100644 --- a/spec/frontend/merge_request/components/status_box_spec.js +++ b/spec/frontend/merge_request/components/status_box_spec.js @@ -27,7 +27,7 @@ const testCases = [ name: 'Closed', state: 'closed', class: 'status-box-mr-closed', - icon: 'close', + icon: 'issue-close', }, { name: 'Merged', diff --git a/spec/frontend/mini_pipeline_graph_dropdown_spec.js b/spec/frontend/mini_pipeline_graph_dropdown_spec.js deleted file mode 100644 index ccd5a4ea142..00000000000 --- a/spec/frontend/mini_pipeline_graph_dropdown_spec.js +++ /dev/null @@ -1,104 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import $ from 'jquery'; -import waitForPromises from 'helpers/wait_for_promises'; -import axios from '~/lib/utils/axios_utils'; -import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown'; - -describe('Mini Pipeline Graph Dropdown', () => { - beforeEach(() => { - loadFixtures('static/mini_dropdown_graph.html'); - }); - - describe('When is initialized', () => { - it('should initialize without errors when no options are given', () => { - const miniPipelineGraph = new MiniPipelineGraph(); - - expect(miniPipelineGraph.dropdownListSelector).toEqual('.js-builds-dropdown-container'); - }); - - it('should set the container as the given prop', () => { - const container = '.foo'; - - const miniPipelineGraph = new MiniPipelineGraph({ container }); - - expect(miniPipelineGraph.container).toEqual(container); - }); - }); - - describe('When dropdown is clicked', () => { - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - it('should call getBuildsList', () => { - const getBuildsListSpy = jest - .spyOn(MiniPipelineGraph.prototype, 'getBuildsList') - .mockImplementation(() => {}); - - new MiniPipelineGraph({ container: '.js-builds-dropdown-tests' }).bindEvents(); - - document.querySelector('.js-builds-dropdown-button').click(); - - expect(getBuildsListSpy).toHaveBeenCalled(); - }); - - it('should make a request to the endpoint provided in the html', () => { - const ajaxSpy = jest.spyOn(axios, 'get'); - - mock.onGet('foobar').reply(200, { - html: '', - }); - - new MiniPipelineGraph({ container: '.js-builds-dropdown-tests' }).bindEvents(); - - document.querySelector('.js-builds-dropdown-button').click(); - - expect(ajaxSpy.mock.calls[0][0]).toEqual('foobar'); - }); - - it('should not close when user uses cmd/ctrl + click', (done) => { - mock.onGet('foobar').reply(200, { - html: `<li> - <a class="mini-pipeline-graph-dropdown-item" href="#"> - <span class="ci-status-icon ci-status-icon-failed"></span> - <span>build</span> - </a> - <a class="ci-action-icon-wrapper js-ci-action-icon" href="#"></a> - </li>`, - }); - new MiniPipelineGraph({ container: '.js-builds-dropdown-tests' }).bindEvents(); - - document.querySelector('.js-builds-dropdown-button').click(); - - waitForPromises() - .then(() => { - document.querySelector('a.mini-pipeline-graph-dropdown-item').click(); - }) - .then(waitForPromises) - .then(() => { - expect($('.js-builds-dropdown-list').is(':visible')).toEqual(true); - }) - .then(done) - .catch(done.fail); - }); - - it('should close the dropdown when request returns an error', (done) => { - mock.onGet('foobar').networkError(); - - new MiniPipelineGraph({ container: '.js-builds-dropdown-tests' }).bindEvents(); - - document.querySelector('.js-builds-dropdown-button').click(); - - setImmediate(() => { - expect($('.js-builds-dropdown-tests .dropdown').hasClass('open')).toEqual(false); - done(); - }); - }); - }); -}); diff --git a/spec/frontend/mocks/ce/diffs/workers/tree_worker.js b/spec/frontend/mocks/ce/diffs/workers/tree_worker.js deleted file mode 100644 index 5532a22f8e6..00000000000 --- a/spec/frontend/mocks/ce/diffs/workers/tree_worker.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'helpers/web_worker_mock'; diff --git a/spec/frontend/mocks/ce/ide/lib/diff/diff_worker.js b/spec/frontend/mocks/ce/ide/lib/diff/diff_worker.js deleted file mode 100644 index 5532a22f8e6..00000000000 --- a/spec/frontend/mocks/ce/ide/lib/diff/diff_worker.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'helpers/web_worker_mock'; diff --git a/spec/frontend/mr_notes/stores/actions_spec.js b/spec/frontend/mr_notes/stores/actions_spec.js new file mode 100644 index 00000000000..c6578453d85 --- /dev/null +++ b/spec/frontend/mr_notes/stores/actions_spec.js @@ -0,0 +1,92 @@ +import MockAdapter from 'axios-mock-adapter'; + +import testAction from 'helpers/vuex_action_helper'; +import axios from '~/lib/utils/axios_utils'; + +import { setEndpoints, setMrMetadata, fetchMrMetadata } from '~/mr_notes/stores/actions'; +import mutationTypes from '~/mr_notes/stores/mutation_types'; + +describe('MR Notes Mutator Actions', () => { + describe('setEndpoints', () => { + it('should trigger the SET_ENDPOINTS state mutation', (done) => { + const endpoints = { endpointA: 'a' }; + + testAction( + setEndpoints, + endpoints, + {}, + [ + { + type: mutationTypes.SET_ENDPOINTS, + payload: endpoints, + }, + ], + [], + done, + ); + }); + }); + + describe('setMrMetadata', () => { + it('should trigger the SET_MR_METADATA state mutation', async () => { + const mrMetadata = { propA: 'a', propB: 'b' }; + + await testAction( + setMrMetadata, + mrMetadata, + {}, + [ + { + type: mutationTypes.SET_MR_METADATA, + payload: mrMetadata, + }, + ], + [], + ); + }); + }); + + describe('fetchMrMetadata', () => { + const mrMetadata = { meta: true, data: 'foo' }; + const state = { + endpoints: { + metadata: 'metadata', + }, + }; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + + mock.onGet(state.endpoints.metadata).reply(200, mrMetadata); + }); + + afterEach(() => { + mock.restore(); + }); + + it('should fetch the data from the API', async () => { + await fetchMrMetadata({ state, dispatch: () => {} }); + + await axios.waitForAll(); + + expect(mock.history.get).toHaveLength(1); + expect(mock.history.get[0].url).toBe(state.endpoints.metadata); + }); + + it('should set the fetched data into state', () => { + return testAction( + fetchMrMetadata, + {}, + state, + [], + [ + { + type: 'setMrMetadata', + payload: mrMetadata, + }, + ], + ); + }); + }); +}); diff --git a/spec/frontend/mr_notes/stores/mutations_spec.js b/spec/frontend/mr_notes/stores/mutations_spec.js new file mode 100644 index 00000000000..35b8a2e4be2 --- /dev/null +++ b/spec/frontend/mr_notes/stores/mutations_spec.js @@ -0,0 +1,27 @@ +import mutationTypes from '~/mr_notes/stores/mutation_types'; +import mutations from '~/mr_notes/stores/mutations'; + +describe('MR Notes Mutations', () => { + describe(mutationTypes.SET_ENDPOINTS, () => { + it('should set the endpoints value', () => { + const state = {}; + const endpoints = { endpointA: 'A', endpointB: 'B' }; + + mutations[mutationTypes.SET_ENDPOINTS](state, endpoints); + + expect(state.endpoints).toEqual(endpoints); + }); + }); + + describe(mutationTypes.SET_MR_METADATA, () => { + it('store the provided MR Metadata in the state', () => { + const state = {}; + const metadata = { propA: 'A', propB: 'B' }; + + mutations[mutationTypes.SET_MR_METADATA](state, metadata); + + expect(state.mrMetadata.propA).toBe('A'); + expect(state.mrMetadata.propB).toBe('B'); + }); + }); +}); diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index bab90723578..b717bab7c3f 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -1,10 +1,11 @@ -import { GlDropdown, GlAlert } from '@gitlab/ui'; +import { GlAlert } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import Autosize from 'autosize'; import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import batchComments from '~/batch_comments/stores/modules/batch_comments'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { deprecatedCreateFlash as flash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; @@ -29,8 +30,10 @@ describe('issue_comment_form component', () => { const findCloseReopenButton = () => wrapper.findByTestId('close-reopen-button'); const findTextArea = () => wrapper.findByTestId('comment-field'); + 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.find(GlDropdown); + const findCommentGlDropdown = () => wrapper.findByTestId('comment-button'); const findCommentButton = () => findCommentGlDropdown().find('button'); const findErrorAlerts = () => wrapper.findAllComponents(GlAlert).wrappers; @@ -582,4 +585,64 @@ describe('issue_comment_form component', () => { expect(findTextArea().exists()).toBe(false); }); }); + + describe('with batchComments in store', () => { + beforeEach(() => { + store.registerModule('batchComments', batchComments()); + }); + + describe('add to review and comment now buttons', () => { + it('when no drafts exist, should not render', () => { + mountComponent(); + + expect(findCommentGlDropdown().exists()).toBe(true); + expect(findAddToReviewButton().exists()).toBe(false); + expect(findAddCommentNowButton().exists()).toBe(false); + }); + + describe('when drafts exist', () => { + beforeEach(() => { + store.state.batchComments.drafts = [{ note: 'A' }]; + }); + + it('should render', () => { + mountComponent(); + + expect(findCommentGlDropdown().exists()).toBe(false); + expect(findAddToReviewButton().exists()).toBe(true); + expect(findAddCommentNowButton().exists()).toBe(true); + }); + + it('clicking `add to review`, should call draft endpoint, set `isDraft` true', () => { + mountComponent({ mountFunction: mount, initialData: { note: 'a draft note' } }); + + jest.spyOn(store, 'dispatch').mockResolvedValue(); + findAddToReviewButton().trigger('click'); + + expect(store.dispatch).toHaveBeenCalledWith( + 'saveNote', + expect.objectContaining({ + endpoint: notesDataMock.draftsPath, + isDraft: true, + }), + ); + }); + + it('clicking `add comment now`, should call note endpoint, set `isDraft` false ', () => { + mountComponent({ mountFunction: mount, initialData: { note: 'a comment' } }); + + jest.spyOn(store, 'dispatch').mockResolvedValue(); + findAddCommentNowButton().trigger('click'); + + expect(store.dispatch).toHaveBeenCalledWith( + 'saveNote', + expect.objectContaining({ + endpoint: noteableDataMock.create_note_path, + isDraft: false, + }), + ); + }); + }); + }); + }); }); diff --git a/spec/frontend/notes/components/discussion_navigator_spec.js b/spec/frontend/notes/components/discussion_navigator_spec.js index 4d55eee2ffa..e430e18b76a 100644 --- a/spec/frontend/notes/components/discussion_navigator_spec.js +++ b/spec/frontend/notes/components/discussion_navigator_spec.js @@ -2,6 +2,11 @@ import 'mousetrap'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vue from 'vue'; +import { + keysFor, + MR_NEXT_UNRESOLVED_DISCUSSION, + MR_PREVIOUS_UNRESOLVED_DISCUSSION, +} from '~/behaviors/shortcuts/keybindings'; import DiscussionNavigator from '~/notes/components/discussion_navigator.vue'; import eventHub from '~/notes/event_hub'; @@ -60,13 +65,13 @@ describe('notes/components/discussion_navigator', () => { }); it('calls jumpToNextDiscussion when pressing `n`', () => { - Mousetrap.trigger('n'); + Mousetrap.trigger(keysFor(MR_NEXT_UNRESOLVED_DISCUSSION)); expect(jumpToNextDiscussion).toHaveBeenCalled(); }); it('calls jumpToPreviousDiscussion when pressing `p`', () => { - Mousetrap.trigger('p'); + Mousetrap.trigger(keysFor(MR_PREVIOUS_UNRESOLVED_DISCUSSION)); expect(jumpToPreviousDiscussion).toHaveBeenCalled(); }); @@ -87,8 +92,8 @@ describe('notes/components/discussion_navigator', () => { }); it('unbinds keys', () => { - expect(Mousetrap.unbind).toHaveBeenCalledWith('n'); - expect(Mousetrap.unbind).toHaveBeenCalledWith('p'); + expect(Mousetrap.unbind).toHaveBeenCalledWith(keysFor(MR_NEXT_UNRESOLVED_DISCUSSION)); + expect(Mousetrap.unbind).toHaveBeenCalledWith(keysFor(MR_PREVIOUS_UNRESOLVED_DISCUSSION)); }); it('unbinds event hub listeners', () => { diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js index cc41088e21e..ecce854b00a 100644 --- a/spec/frontend/notes/components/note_actions_spec.js +++ b/spec/frontend/notes/components/note_actions_spec.js @@ -151,6 +151,22 @@ describe('noteActions', () => { const assignUserButton = wrapper.find('[data-testid="assign-user"]'); expect(assignUserButton.exists()).toBe(false); }); + + it('should render the correct (unescaped) name in the Resolved By tooltip', () => { + const complexUnescapedName = 'This is a Ǝ\'𝞓\'E "cat"?'; + wrapper = mountNoteActions({ + ...props, + canResolve: true, + isResolving: false, + isResolved: true, + resolvedBy: { + name: complexUnescapedName, + }, + }); + + const { resolveButton } = wrapper.vm.$refs; + expect(resolveButton.$el.getAttribute('title')).toBe(`Resolved by ${complexUnescapedName}`); + }); }); }); diff --git a/spec/frontend/notes/components/note_body_spec.js b/spec/frontend/notes/components/note_body_spec.js index 4922de987fa..40251244423 100644 --- a/spec/frontend/notes/components/note_body_spec.js +++ b/spec/frontend/notes/components/note_body_spec.js @@ -81,14 +81,21 @@ describe('issue_note_body component', () => { state: { defaultSuggestionCommitMessage: '%{branch_name}%{project_path}%{project_name}%{username}%{user_full_name}%{file_paths}%{suggestions_count}%{files_count}', - branchName: 'branch', - projectPath: '/path', - projectName: 'name', - username: 'user', - userFullName: 'user userton', }, getters: { suggestionCommitMessage }, }, + page: { + namespaced: true, + state: { + mrMetadata: { + branch_name: 'branch', + project_path: '/path', + project_name: 'name', + username: 'user', + user_full_name: 'user userton', + }, + }, + }, }, }); diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js index dd65351ef88..735bc2b70dd 100644 --- a/spec/frontend/notes/components/noteable_discussion_spec.js +++ b/spec/frontend/notes/components/noteable_discussion_spec.js @@ -124,14 +124,7 @@ describe('noteable_discussion component', () => { ...getJSONFixture(discussionWithTwoUnresolvedNotes)[0], expanded: true, }; - discussion.notes = discussion.notes.map((note) => ({ - ...note, - resolved: false, - current_user: { - ...note.current_user, - can_resolve: true, - }, - })); + discussion.resolved = false; wrapper.setProps({ discussion }); diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js index 112983f3ac2..7444c441e06 100644 --- a/spec/frontend/notes/components/noteable_note_spec.js +++ b/spec/frontend/notes/components/noteable_note_spec.js @@ -1,32 +1,65 @@ -import { mount, createLocalVue } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import { escape } from 'lodash'; +import Vue from 'vue'; +import Vuex from 'vuex'; + import waitForPromises from 'helpers/wait_for_promises'; + +import DiffsModule from '~/diffs/store/modules'; + import NoteActions from '~/notes/components/note_actions.vue'; import NoteBody from '~/notes/components/note_body.vue'; import NoteHeader from '~/notes/components/note_header.vue'; import issueNote from '~/notes/components/noteable_note.vue'; -import createStore from '~/notes/stores'; +import NotesModule from '~/notes/stores/modules'; + import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; + import { noteableDataMock, notesDataMock, note } from '../mock_data'; +Vue.use(Vuex); + +const singleLineNotePosition = { + line_range: { + start: { + line_code: 'abc_1_1', + type: null, + old_line: '1', + new_line: '1', + }, + end: { + line_code: 'abc_1_1', + type: null, + old_line: '1', + new_line: '1', + }, + }, +}; + describe('issue_note', () => { let store; let wrapper; const findMultilineComment = () => wrapper.find('[data-testid="multiline-comment"]'); - const createWrapper = (props = {}) => { - store = createStore(); + const createWrapper = (props = {}, storeUpdater = (s) => s) => { + store = new Vuex.Store( + storeUpdater({ + modules: { + notes: NotesModule(), + diffs: DiffsModule(), + }, + }), + ); + store.dispatch('setNoteableData', noteableDataMock); store.dispatch('setNotesData', notesDataMock); - const localVue = createLocalVue(); - wrapper = mount(localVue.extend(issueNote), { + wrapper = mount(issueNote, { store, propsData: { note, ...props, }, - localVue, stubs: [ 'note-header', 'user-avatar-link', @@ -216,9 +249,13 @@ describe('issue_note', () => { const noteBodyComponent = wrapper.findComponent(NoteBody); store.hotUpdate({ - actions: { - updateNote() {}, - setSelectedCommentPositionHover() {}, + modules: { + notes: { + actions: { + updateNote() {}, + setSelectedCommentPositionHover() {}, + }, + }, }, }); @@ -238,8 +275,12 @@ describe('issue_note', () => { it('restores content of updated note', async () => { const updatedText = 'updated note text'; store.hotUpdate({ - actions: { - updateNote() {}, + modules: { + notes: { + actions: { + updateNote() {}, + }, + }, }, }); const noteBody = wrapper.findComponent(NoteBody); @@ -267,9 +308,13 @@ describe('issue_note', () => { const updateActions = () => { store.hotUpdate({ - actions: { - updateNote, - setSelectedCommentPositionHover() {}, + modules: { + notes: { + actions: { + updateNote, + setSelectedCommentPositionHover() {}, + }, + }, }, }); }; @@ -299,4 +344,62 @@ describe('issue_note', () => { expect(updateNote.mock.calls[0][1].note.note.position).toBe(expectation); }); }); + + describe('diffFile', () => { + it.each` + scenario | files | noteDef + ${'the note has no position'} | ${undefined} | ${note} + ${'the Diffs store has no data'} | ${[]} | ${{ ...note, position: singleLineNotePosition }} + `( + 'returns `null` when $scenario and no diff file is provided as a prop', + ({ noteDef, diffs }) => { + const storeUpdater = (rawStore) => { + const updatedStore = { ...rawStore }; + + if (diffs) { + updatedStore.modules.diffs.state.diffFiles = diffs; + } + + return updatedStore; + }; + + createWrapper({ note: noteDef, discussionFile: null }, storeUpdater); + + expect(wrapper.vm.diffFile).toBe(null); + }, + ); + + it("returns the correct diff file from the Diffs store if it's available", () => { + createWrapper( + { + note: { ...note, position: singleLineNotePosition }, + }, + (rawStore) => { + const updatedStore = { ...rawStore }; + updatedStore.modules.diffs.state.diffFiles = [ + { file_hash: 'abc', testId: 'diffFileTest' }, + ]; + return updatedStore; + }, + ); + + expect(wrapper.vm.diffFile.testId).toBe('diffFileTest'); + }); + + it('returns the provided diff file if the more robust getters fail', () => { + createWrapper( + { + note: { ...note, position: singleLineNotePosition }, + discussionFile: { testId: 'diffFileTest' }, + }, + (rawStore) => { + const updatedStore = { ...rawStore }; + updatedStore.modules.diffs.state.diffFiles = []; + return updatedStore; + }, + ); + + expect(wrapper.vm.diffFile.testId).toBe('diffFileTest'); + }); + }); }); diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js index 163501d5ce8..241a89b2218 100644 --- a/spec/frontend/notes/components/notes_app_spec.js +++ b/spec/frontend/notes/components/notes_app_spec.js @@ -3,6 +3,8 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import Vue from 'vue'; import { setTestTimeout } from 'helpers/timeout'; +import DraftNote from '~/batch_comments/components/draft_note.vue'; +import batchComments from '~/batch_comments/stores/modules/batch_comments'; import axios from '~/lib/utils/axios_utils'; import * as urlUtility from '~/lib/utils/url_utility'; import CommentForm from '~/notes/components/comment_form.vue'; @@ -400,4 +402,32 @@ describe('note_app', () => { expect(getComponentOrder()).toStrictEqual([TYPE_NOTES_LIST, TYPE_COMMENT_FORM]); }); }); + + describe('when multiple draft types are present', () => { + beforeEach(() => { + store = createStore(); + store.registerModule('batchComments', batchComments()); + store.state.batchComments.drafts = [ + mockData.draftDiffDiscussion, + mockData.draftReply, + ...mockData.draftComments, + ]; + store.state.isLoading = false; + wrapper = shallowMount(NotesApp, { + propsData, + store, + stubs: { + OrderedLayout, + }, + }); + }); + + it('correctly finds only draft comments', () => { + const drafts = wrapper.findAll(DraftNote).wrappers; + + expect(drafts.map((x) => x.props('draft'))).toEqual( + mockData.draftComments.map(({ note }) => expect.objectContaining({ note })), + ); + }); + }); }); diff --git a/spec/frontend/notes/mock_data.js b/spec/frontend/notes/mock_data.js index 638a4edecd6..a4aeeda48d8 100644 --- a/spec/frontend/notes/mock_data.js +++ b/spec/frontend/notes/mock_data.js @@ -6,6 +6,7 @@ export const notesDataMock = { markdownDocsPath: '/help/user/markdown', newSessionPath: '/users/sign_in?redirect_to_referer=yes', notesPath: '/gitlab-org/gitlab-foss/noteable/issue/98/notes', + draftsPath: '/flightjs/flight/-/merge_requests/4/drafts', quickActionsDocsPath: '/help/user/project/quick_actions', registerPath: '/users/sign_up?redirect_to_referer=yes', prerenderedNotesCount: 1, @@ -1270,3 +1271,18 @@ export const batchSuggestionsInfoMock = [ discussionId: 'c003', }, ]; + +export const draftComments = [ + { id: 7, note: 'test draft note', isDraft: true }, + { id: 9, note: 'draft note 2', isDraft: true }, +]; + +export const draftReply = { id: 8, note: 'draft reply', discussion_id: 1, isDraft: true }; + +export const draftDiffDiscussion = { + id: 6, + note: 'draft diff discussion', + line_code: 1, + file_path: 'lib/foo.rb', + isDraft: true, +}; diff --git a/spec/frontend/notes/stores/getters_spec.js b/spec/frontend/notes/stores/getters_spec.js index 4d2f86a1ecf..3adb5da020e 100644 --- a/spec/frontend/notes/stores/getters_spec.js +++ b/spec/frontend/notes/stores/getters_spec.js @@ -1,4 +1,4 @@ -import { DESC } from '~/notes/constants'; +import { DESC, ASC } from '~/notes/constants'; import * as getters from '~/notes/stores/getters'; import { notesDataMock, @@ -12,6 +12,9 @@ import { discussion3, resolvedDiscussion1, unresolvableDiscussion, + draftComments, + draftReply, + draftDiffDiscussion, } from '../mock_data'; const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json'; @@ -23,6 +26,8 @@ const createDiscussionNeighborParams = (discussionId, diffOrder, step) => ({ step, }); +const asDraftDiscussion = (x) => ({ ...x, individual_note: true }); + describe('Getters Notes Store', () => { let state; @@ -61,20 +66,58 @@ describe('Getters Notes Store', () => { }); describe('discussions', () => { - it('should return all discussions in the store', () => { - expect(getters.discussions(state)).toEqual([individualNote]); - }); + let batchComments = null; + + const getDiscussions = () => getters.discussions(state, {}, { batchComments }); + + describe('without batchComments module', () => { + it('should return all discussions in the store', () => { + expect(getDiscussions()).toEqual([individualNote]); + }); + + it('should transform discussion to individual notes in timeline view', () => { + state.discussions = [discussionMock]; + state.isTimelineEnabled = true; - it('should transform discussion to individual notes in timeline view', () => { - state.discussions = [discussionMock]; - state.isTimelineEnabled = true; + const discussions = getDiscussions(); + + expect(discussions.length).toEqual(discussionMock.notes.length); + discussions.forEach((discussion) => { + expect(discussion.individual_note).toBe(true); + expect(discussion.id).toBe(discussion.notes[0].id); + expect(discussion.created_at).toBe(discussion.notes[0].created_at); + }); + }); + }); - expect(getters.discussions(state).length).toEqual(discussionMock.notes.length); - getters.discussions(state).forEach((discussion) => { - expect(discussion.individual_note).toBe(true); - expect(discussion.id).toBe(discussion.notes[0].id); - expect(discussion.created_at).toBe(discussion.notes[0].created_at); + describe('with batchComments', () => { + beforeEach(() => { + batchComments = { drafts: [...draftComments, draftReply, draftDiffDiscussion] }; }); + + it.each` + discussionSortOrder | expectation + ${ASC} | ${[individualNote, ...draftComments.map(asDraftDiscussion)]} + ${DESC} | ${[...draftComments.reverse().map(asDraftDiscussion), individualNote]} + `( + 'only appends draft comments (discussionSortOrder=$discussionSortOrder)', + ({ discussionSortOrder, expectation }) => { + state.discussionSortOrder = discussionSortOrder; + + expect(getDiscussions()).toEqual(expectation); + }, + ); + }); + }); + + describe('hasDrafts', () => { + it.each` + rootGetters | expected + ${{}} | ${false} + ${{ 'batchComments/hasDrafts': true }} | ${true} + ${{ 'batchComments/hasDrafts': false }} | ${false} + `('with rootGetters=$rootGetters, returns $expected', ({ rootGetters, expected }) => { + expect(getters.hasDrafts({}, {}, {}, rootGetters)).toBe(expected); }); }); @@ -103,7 +146,7 @@ describe('Getters Notes Store', () => { }; it('should return a single system note when a description was updated multiple times', () => { - expect(getters.discussions(stateCollapsedNotes).length).toEqual(1); + expect(getters.discussions(stateCollapsedNotes, {}, {}).length).toEqual(1); }); }); diff --git a/spec/frontend/packages/details/store/getters_spec.js b/spec/frontend/packages/details/store/getters_spec.js index f12b75d3b70..005adece56e 100644 --- a/spec/frontend/packages/details/store/getters_spec.js +++ b/spec/frontend/packages/details/store/getters_spec.js @@ -27,6 +27,7 @@ import { mockPipelineInfo, mavenPackage as packageWithoutBuildInfo, pypiPackage, + rubygemsPackage, } from '../../mock_data'; import { generateMavenCommand, @@ -104,6 +105,7 @@ describe('Getters PackageDetails Store', () => { ${npmPackage} | ${'npm'} ${nugetPackage} | ${'NuGet'} ${pypiPackage} | ${'PyPI'} + ${rubygemsPackage} | ${'RubyGems'} `(`package type`, ({ packageEntity, expectedResult }) => { beforeEach(() => setupState({ packageEntity })); diff --git a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap index 3f17731584c..07aba62fef6 100644 --- a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap +++ b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap @@ -2,11 +2,11 @@ exports[`packages_list_app renders 1`] = ` <div> - <package-title-stub - packagehelpurl="foo" + <div + help-url="foo" /> - <package-search-stub /> + <div /> <div> <section @@ -52,7 +52,9 @@ exports[`packages_list_app renders 1`] = ` with GitLab. </p> - <div> + <div + class="gl-display-flex gl-flex-wrap gl-justify-content-center" + > <!----> <!----> diff --git a/spec/frontend/packages/list/components/packages_list_app_spec.js b/spec/frontend/packages/list/components/packages_list_app_spec.js index 6862d23c4ff..4de2dd0789e 100644 --- a/spec/frontend/packages/list/components/packages_list_app_spec.js +++ b/spec/frontend/packages/list/components/packages_list_app_spec.js @@ -3,10 +3,11 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import createFlash from '~/flash'; import * as commonUtils from '~/lib/utils/common_utils'; -import PackageSearch from '~/packages/list/components/package_search.vue'; import PackageListApp from '~/packages/list/components/packages_list_app.vue'; import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants'; import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; +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'); @@ -24,10 +25,19 @@ describe('packages_list_app', () => { }; 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({ @@ -45,7 +55,7 @@ describe('packages_list_app', () => { store.dispatch = jest.fn(); }; - const mountComponent = () => { + const mountComponent = (provide) => { wrapper = shallowMount(PackageListApp, { localVue, store, @@ -55,12 +65,18 @@ describe('packages_list_app', () => { PackageList, GlSprintf, GlLink, + PackageSearch, + PackageTitle, + InfrastructureTitle, + InfrastructureSearch, }, + provide, }); }; beforeEach(() => { createStore(); + jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue({}); }); afterEach(() => { @@ -72,25 +88,6 @@ describe('packages_list_app', () => { expect(wrapper.element).toMatchSnapshot(); }); - 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'); - }); - }); - it('call requestPackagesList on page:changed', () => { mountComponent(); store.dispatch.mockClear(); @@ -108,10 +105,75 @@ describe('packages_list_app', () => { expect(store.dispatch).toHaveBeenCalledWith('requestDeletePackage', 'foo'); }); - it('does not call requestPackagesList two times on render', () => { + it('does call requestPackagesList only one time on render', () => { mountComponent(); - expect(store.dispatch).toHaveBeenCalledTimes(1); + 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', () => { @@ -145,6 +207,31 @@ describe('packages_list_app', () => { }); }); + 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 { location } = window.location; const search = `?${SHOW_DELETE_SUCCESS_ALERT}=true`; diff --git a/spec/frontend/packages/list/components/packages_search_spec.js b/spec/frontend/packages/list/components/packages_search_spec.js index 9b62dde8d2b..30fad74b493 100644 --- a/spec/frontend/packages/list/components/packages_search_spec.js +++ b/spec/frontend/packages/list/components/packages_search_spec.js @@ -2,8 +2,9 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import component from '~/packages/list/components/package_search.vue'; import PackageTypeToken from '~/packages/list/components/tokens/package_type_token.vue'; -import getTableHeaders from '~/packages/list/utils'; +import { sortableFields } from '~/packages/list/utils'; 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); @@ -12,7 +13,8 @@ describe('Package Search', () => { let wrapper; let store; - const findRegistrySearch = () => wrapper.find(RegistrySearch); + const findRegistrySearch = () => wrapper.findComponent(RegistrySearch); + const findUrlSync = () => wrapper.findComponent(UrlSync); const createStore = (isGroupPage) => { const state = { @@ -37,6 +39,9 @@ describe('Package Search', () => { wrapper = shallowMount(component, { localVue, store, + stubs: { + UrlSync, + }, }); }; @@ -55,7 +60,7 @@ describe('Package Search', () => { tokens: expect.arrayContaining([ expect.objectContaining({ token: PackageTypeToken, type: 'type', icon: 'package' }), ]), - sortableFields: getTableHeaders(), + sortableFields: sortableFields(), }); }); @@ -72,7 +77,7 @@ describe('Package Search', () => { tokens: expect.arrayContaining([ expect.objectContaining({ token: PackageTypeToken, type: 'type', icon: 'package' }), ]), - sortableFields: getTableHeaders(isGroupPage), + sortableFields: sortableFields(isGroupPage), }); }); @@ -104,4 +109,20 @@ describe('Package Search', () => { 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/list/components/packages_title_spec.js b/spec/frontend/packages/list/components/packages_title_spec.js index 3716e8daa7c..a17f72e3133 100644 --- a/spec/frontend/packages/list/components/packages_title_spec.js +++ b/spec/frontend/packages/list/components/packages_title_spec.js @@ -11,7 +11,7 @@ describe('PackageTitle', () => { const findTitleArea = () => wrapper.find(TitleArea); const findMetadataItem = () => wrapper.find(MetadataItem); - const mountComponent = (propsData = { packageHelpUrl: 'foo' }) => { + const mountComponent = (propsData = { helpUrl: 'foo' }) => { wrapper = shallowMount(PackageTitle, { store, propsData, @@ -44,15 +44,15 @@ describe('PackageTitle', () => { }); describe.each` - packagesCount | exist | text - ${null} | ${false} | ${''} - ${undefined} | ${false} | ${''} - ${0} | ${true} | ${'0 Packages'} - ${1} | ${true} | ${'1 Package'} - ${2} | ${true} | ${'2 Packages'} - `('when packagesCount is $packagesCount metadata item', ({ packagesCount, exist, text }) => { + 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({ packagesCount, packageHelpUrl: 'foo' }); + mountComponent({ count, helpUrl: 'foo' }); }); it(`is ${exist} that it exists`, () => { diff --git a/spec/frontend/packages/list/utils_spec.js b/spec/frontend/packages/list/utils_spec.js index 5bcc3784752..4e4f7b8a723 100644 --- a/spec/frontend/packages/list/utils_spec.js +++ b/spec/frontend/packages/list/utils_spec.js @@ -1,6 +1,15 @@ -import { getNewPaginationPage } from '~/packages/list/utils'; +import { SORT_FIELDS } from '~/packages/list/constants'; +import { getNewPaginationPage, sortableFields } from '~/packages/list/utils'; describe('Packages list utils', () => { + describe('sortableFields', () => { + it('returns the correct list when is a project page', () => { + expect(sortableFields()).toEqual(SORT_FIELDS.filter((f) => f.orderBy !== 'project_path')); + }); + it('returns the full list on the group page', () => { + expect(sortableFields(true)).toEqual(SORT_FIELDS); + }); + }); describe('packageTypeDisplay', () => { it('returns the current page when total items exceeds pagniation', () => { expect(getNewPaginationPage(2, 20, 21)).toBe(2); diff --git a/spec/frontend/packages/mock_data.js b/spec/frontend/packages/mock_data.js index fbc167729d9..06009daba54 100644 --- a/spec/frontend/packages/mock_data.js +++ b/spec/frontend/packages/mock_data.js @@ -134,6 +134,23 @@ export const nugetPackage = { }, }; +export const rubygemsPackage = { + created_at: '2015-12-10', + id: 4, + name: 'RubyGem1', + package_files: [], + package_type: 'rubygems', + project_id: 1, + tags: [], + updated_at: '2015-12-10', + version: '1.0.0', + rubygems_metadatum: { + author: 'Fake Name', + summary: 'My gem', + email: 'tanuki@fake.com', + }, +}; + export const pypiPackage = { created_at: '2015-12-10', id: 5, diff --git a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap index 77095f7c611..03b98478f3e 100644 --- a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap +++ b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap @@ -51,20 +51,7 @@ exports[`packages_list_row renders 1`] = ` <!----> - <div - class="d-flex align-items-center" - data-testid="package-type" - > - <gl-icon-stub - class="gl-ml-3 gl-mr-2" - name="package" - size="16" - /> - - <span> - Maven - </span> - </div> + <div /> <package-path-stub path="foo/bar/baz" diff --git a/spec/frontend/packages/shared/components/package_icon_and_name_spec.js b/spec/frontend/packages/shared/components/package_icon_and_name_spec.js new file mode 100644 index 00000000000..c96a570a29c --- /dev/null +++ b/spec/frontend/packages/shared/components/package_icon_and_name_spec.js @@ -0,0 +1,32 @@ +import { GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import PackageIconAndName from '~/packages/shared/components/package_icon_and_name.vue'; + +describe('PackageIconAndName', () => { + let wrapper; + + const findIcon = () => wrapper.find(GlIcon); + + const mountComponent = () => { + wrapper = shallowMount(PackageIconAndName, { + slots: { + default: 'test', + }, + }); + }; + + it('has an icon', () => { + mountComponent(); + + const icon = findIcon(); + + expect(icon.exists()).toBe(true); + expect(icon.props('name')).toBe('package'); + }); + + it('renders the slot content', () => { + mountComponent(); + + expect(wrapper.text()).toBe('test'); + }); +}); diff --git a/spec/frontend/packages/shared/components/package_list_row_spec.js b/spec/frontend/packages/shared/components/package_list_row_spec.js index 1c0ef7e3539..fd54cd0f25d 100644 --- a/spec/frontend/packages/shared/components/package_list_row_spec.js +++ b/spec/frontend/packages/shared/components/package_list_row_spec.js @@ -1,7 +1,9 @@ import { shallowMount } from '@vue/test-utils'; + import PackagesListRow from '~/packages/shared/components/package_list_row.vue'; import PackagePath from '~/packages/shared/components/package_path.vue'; import PackageTags from '~/packages/shared/components/package_tags.vue'; + import ListItem from '~/vue_shared/components/registry/list_item.vue'; import { packageList } from '../../mock_data'; @@ -11,20 +13,30 @@ describe('packages_list_row', () => { const [packageWithoutTags, packageWithTags] = packageList; + const InfrastructureIconAndName = { name: 'InfrastructureIconAndName', template: '<div></div>' }; + const PackageIconAndName = { name: 'PackageIconAndName', template: '<div></div>' }; + const findPackageTags = () => wrapper.find(PackageTags); const findPackagePath = () => wrapper.find(PackagePath); const findDeleteButton = () => wrapper.find('[data-testid="action-delete"]'); - const findPackageType = () => wrapper.find('[data-testid="package-type"]'); + const findPackageIconAndName = () => wrapper.find(PackageIconAndName); + const findInfrastructureIconAndName = () => wrapper.find(InfrastructureIconAndName); const mountComponent = ({ isGroup = false, packageEntity = packageWithoutTags, showPackageType = true, disableDelete = false, + provide, } = {}) => { wrapper = shallowMount(PackagesListRow, { store, - stubs: { ListItem }, + provide, + stubs: { + ListItem, + InfrastructureIconAndName, + PackageIconAndName, + }, propsData: { packageLink: 'foo', packageEntity, @@ -72,13 +84,13 @@ describe('packages_list_row', () => { it('shows the type when set', () => { mountComponent(); - expect(findPackageType().exists()).toBe(true); + expect(findPackageIconAndName().exists()).toBe(true); }); it('does not show the type when not set', () => { mountComponent({ showPackageType: false }); - expect(findPackageType().exists()).toBe(false); + expect(findPackageIconAndName().exists()).toBe(false); }); }); @@ -113,4 +125,25 @@ describe('packages_list_row', () => { expect(wrapper.emitted('packageToDelete')[0]).toEqual([packageWithoutTags]); }); }); + + describe('Infrastructure config', () => { + it('defaults to package registry components', () => { + mountComponent(); + + expect(findPackageIconAndName().exists()).toBe(true); + expect(findInfrastructureIconAndName().exists()).toBe(false); + }); + + it('mounts different component based on the provided values', () => { + mountComponent({ + provide: { + iconComponent: 'InfrastructureIconAndName', + }, + }); + + expect(findPackageIconAndName().exists()).toBe(false); + + expect(findInfrastructureIconAndName().exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/packages/shared/utils_spec.js b/spec/frontend/packages/shared/utils_spec.js index 4a95def1bef..463e4a4febb 100644 --- a/spec/frontend/packages/shared/utils_spec.js +++ b/spec/frontend/packages/shared/utils_spec.js @@ -38,6 +38,7 @@ describe('Packages shared utils', () => { ${'npm'} | ${'npm'} ${'nuget'} | ${'NuGet'} ${'pypi'} | ${'PyPI'} + ${'rubygems'} | ${'RubyGems'} ${'composer'} | ${'Composer'} ${'foo'} | ${null} `(`package type`, ({ packageType, expectedResult }) => { diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name_spec.js new file mode 100644 index 00000000000..ef26c729691 --- /dev/null +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name_spec.js @@ -0,0 +1,28 @@ +import { GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import InfrastructureIconAndName from '~/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue'; + +describe('InfrastructureIconAndName', () => { + let wrapper; + + const findIcon = () => wrapper.find(GlIcon); + + const mountComponent = () => { + wrapper = shallowMount(InfrastructureIconAndName, {}); + }; + + it('has an icon', () => { + mountComponent(); + + const icon = findIcon(); + + expect(icon.exists()).toBe(true); + expect(icon.props('name')).toBe('infrastructure-registry'); + }); + + it('has the type fixed to terraform', () => { + mountComponent(); + + expect(wrapper.text()).toBe('Terraform'); + }); +}); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_search_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_search_spec.js new file mode 100644 index 00000000000..119b678cc37 --- /dev/null +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_search_spec.js @@ -0,0 +1,135 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import component from '~/packages_and_registries/infrastructure_registry/components/infrastructure_search.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('Infrastructure Search', () => { + let wrapper; + let store; + + const sortableFields = () => [ + { orderBy: 'name', label: 'Name' }, + { orderBy: 'version', label: 'Version' }, + { orderBy: 'created_at', label: 'Published' }, + ]; + + const groupSortableFields = () => [ + { orderBy: 'name', label: 'Name' }, + { orderBy: 'project_path', label: 'Project' }, + { orderBy: 'version', label: 'Version' }, + { orderBy: 'created_at', label: 'Published' }, + ]; + + 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: [], + sortableFields: sortableFields(), + }); + }); + + it.each` + isGroupPage | page | fields + ${false} | ${'project'} | ${sortableFields()} + ${true} | ${'group'} | ${groupSortableFields()} + `('in a $page page binds the right props', ({ isGroupPage, fields }) => { + mountComponent(isGroupPage); + + expect(findRegistrySearch().props()).toMatchObject({ + filter: store.state.filter, + sorting: store.state.sorting, + tokens: [], + sortableFields: fields, + }); + }); + + 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/infrastructure_registry/components/infrastructure_title_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_title_spec.js new file mode 100644 index 00000000000..db6e175b054 --- /dev/null +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_title_spec.js @@ -0,0 +1,75 @@ +import { shallowMount } from '@vue/test-utils'; +import component from '~/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue'; +import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; + +describe('Infrastructure Title', () => { + let wrapper; + let store; + + const findTitleArea = () => wrapper.find(TitleArea); + const findMetadataItem = () => wrapper.find(MetadataItem); + + const mountComponent = (propsData = { helpUrl: 'foo' }) => { + wrapper = shallowMount(component, { + 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: 'Infrastructure Registry', + infoMessages: [ + { + text: 'Publish and share your modules. %{docLinkStart}More information%{docLinkEnd}', + link: 'foo', + }, + ], + }); + }); + }); + + describe.each` + count | exist | text + ${null} | ${false} | ${''} + ${undefined} | ${false} | ${''} + ${0} | ${true} | ${'0 Modules'} + ${1} | ${true} | ${'1 Module'} + ${2} | ${true} | ${'2 Modules'} + `('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: 'infrastructure-registry', + text, + }); + }); + } + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/group/components/maven_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/maven_settings_spec.js index 2433c50ff24..859d3587223 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/maven_settings_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/maven_settings_spec.js @@ -59,7 +59,10 @@ describe('Maven Settings', () => { mountComponent(); expect(findToggle().exists()).toBe(true); - expect(findToggle().props('value')).toBe(defaultProps.mavenDuplicatesAllowed); + expect(findToggle().props()).toMatchObject({ + label: component.i18n.MAVEN_TOGGLE_LABEL, + value: defaultProps.mavenDuplicatesAllowed, + }); }); it('toggle emits an update event', () => { diff --git a/spec/frontend/packages_and_registries/shared/utils_spec.js b/spec/frontend/packages_and_registries/shared/utils_spec.js new file mode 100644 index 00000000000..bbc8791ca21 --- /dev/null +++ b/spec/frontend/packages_and_registries/shared/utils_spec.js @@ -0,0 +1,59 @@ +import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; +import { + getQueryParams, + keyValueToFilterToken, + searchArrayToFilterTokens, + extractFilterAndSorting, +} from '~/packages_and_registries/shared/utils'; + +describe('Packages And Registries shared utils', () => { + describe('getQueryParams', () => { + it('returns an object from a query string, with arrays', () => { + const queryString = 'foo=bar&baz[]=1&baz[]=2'; + + expect(getQueryParams(queryString)).toStrictEqual({ foo: 'bar', baz: ['1', '2'] }); + }); + }); + + describe('keyValueToFilterToken', () => { + it('returns an object in the correct form', () => { + const type = 'myType'; + const data = 1; + + expect(keyValueToFilterToken(type, data)).toStrictEqual({ type, value: { data } }); + }); + }); + + describe('searchArrayToFilterTokens', () => { + it('returns an array of objects in the correct form', () => { + const search = ['one', 'two']; + + expect(searchArrayToFilterTokens(search)).toStrictEqual([ + { type: FILTERED_SEARCH_TERM, value: { data: 'one' } }, + { type: FILTERED_SEARCH_TERM, value: { data: 'two' } }, + ]); + }); + }); + describe('extractFilterAndSorting', () => { + it.each` + search | type | sort | orderBy | result + ${['one']} | ${'myType'} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [{ type: 'type', value: { data: 'myType' } }, { type: FILTERED_SEARCH_TERM, value: { data: 'one' } }] }} + ${['one']} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [{ type: FILTERED_SEARCH_TERM, value: { data: 'one' } }] }} + ${[]} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [] }} + ${null} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [] }} + ${null} | ${null} | ${null} | ${'foo'} | ${{ sorting: { orderBy: 'foo' }, filters: [] }} + ${null} | ${null} | ${null} | ${null} | ${{ sorting: {}, filters: [] }} + `( + 'returns sorting and filters objects in the correct form', + ({ search, type, sort, orderBy, result }) => { + const queryObject = { + search, + type, + sort, + orderBy, + }; + expect(extractFilterAndSorting(queryObject)).toStrictEqual(result); + }, + ); + }); +}); diff --git a/spec/frontend/pager_spec.js b/spec/frontend/pager_spec.js index ad4222e7cb2..95679a51c6d 100644 --- a/spec/frontend/pager_spec.js +++ b/spec/frontend/pager_spec.js @@ -32,38 +32,12 @@ describe('pager', () => { window.history.replaceState({}, null, originalHref); }); - it('should use data-href attribute from list element', () => { - const href = `${TEST_HOST}/some_list.json`; - setFixtures(`<div class="content_list" data-href="${href}"></div>`); - Pager.init(); - - expect(Pager.url).toBe(href); - }); - - it('should use current url if data-href attribute not provided', () => { - const href = `${TEST_HOST}/some_list`; - removeParams.mockReturnValue(href); - Pager.init(); - - expect(Pager.url).toBe(href); - }); - it('should get initial offset from query parameter', () => { window.history.replaceState({}, null, '?offset=100'); Pager.init(); expect(Pager.offset).toBe(100); }); - - it('keeps extra query parameters from url', () => { - window.history.replaceState({}, null, '?filter=test&offset=100'); - const href = `${TEST_HOST}/some_list?filter=test`; - removeParams.mockReturnValue(href); - Pager.init(); - - expect(removeParams).toHaveBeenCalledWith(['limit', 'offset']); - expect(Pager.url).toEqual(href); - }); }); describe('getOld', () => { @@ -164,5 +138,50 @@ describe('pager', () => { done(); }); }); + + describe('has data-href attribute from list element', () => { + const href = `${TEST_HOST}/some_list.json`; + + beforeEach(() => { + setFixtures(`<div class="content_list" data-href="${href}"></div>`); + }); + + it('should use data-href attribute', () => { + Pager.getOld(); + + expect(axios.get).toHaveBeenCalledWith(href, expect.any(Object)); + }); + + it('should not use current url', () => { + Pager.getOld(); + + expect(removeParams).not.toHaveBeenCalled(); + expect(removeParams).not.toHaveBeenCalledWith(href); + }); + }); + + describe('no data-href attribute attribute provided from list element', () => { + beforeEach(() => { + setFixtures(`<div class="content_list"></div>`); + }); + + it('should use current url', () => { + const href = `${TEST_HOST}/some_list`; + removeParams.mockReturnValue(href); + Pager.getOld(); + + expect(axios.get).toHaveBeenCalledWith(href, expect.any(Object)); + }); + + it('keeps extra query parameters from url', () => { + window.history.replaceState({}, null, '?filter=test&offset=100'); + const href = `${TEST_HOST}/some_list?filter=test`; + removeParams.mockReturnValue(href); + Pager.getOld(); + + expect(removeParams).toHaveBeenCalledWith(['limit', 'offset']); + expect(axios.get).toHaveBeenCalledWith(href, expect.any(Object)); + }); + }); }); }); diff --git a/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap index ddeaa2a79db..9f02e5b9432 100644 --- a/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap +++ b/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap @@ -50,11 +50,11 @@ exports[`User Operation confirmation modal renders modal with form included 1`] <gl-button-stub buttontextclasses="" - category="primary" + category="secondary" disabled="true" icon="" size="medium" - variant="warning" + variant="danger" > secondaryAction diff --git a/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js b/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js index c7293b00adf..318b6d16008 100644 --- a/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js +++ b/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js @@ -11,15 +11,15 @@ describe('User Operation confirmation modal', () => { let wrapper; let formSubmitSpy; - const findButton = (variant) => + const findButton = (variant, category) => wrapper .findAll(GlButton) - .filter((w) => w.attributes('variant') === variant) + .filter((w) => w.attributes('variant') === variant && w.attributes('category') === category) .at(0); const findForm = () => wrapper.find('form'); const findUsernameInput = () => wrapper.find(GlFormInput); - const findPrimaryButton = () => findButton('danger'); - const findSecondaryButton = () => findButton('warning'); + const findPrimaryButton = () => findButton('danger', 'primary'); + const findSecondaryButton = () => findButton('danger', 'secondary'); const findAuthenticityToken = () => new FormData(findForm().element).get('authenticity_token'); const getUsername = () => findUsernameInput().attributes('value'); const getMethodParam = () => new FormData(findForm().element).get('_method'); diff --git a/spec/frontend/pages/admin/users/new/index_spec.js b/spec/frontend/pages/admin/users/new/index_spec.js deleted file mode 100644 index ec9fe487030..00000000000 --- a/spec/frontend/pages/admin/users/new/index_spec.js +++ /dev/null @@ -1,41 +0,0 @@ -import $ from 'jquery'; -import UserInternalRegexHandler from '~/pages/admin/users/new/index'; - -describe('UserInternalRegexHandler', () => { - const FIXTURE = 'admin/users/new_with_internal_user_regex.html'; - let $userExternal; - let $userEmail; - let $warningMessage; - - beforeEach(() => { - loadFixtures(FIXTURE); - // eslint-disable-next-line no-new - new UserInternalRegexHandler(); - $userExternal = $('#user_external'); - $userEmail = $('#user_email'); - $warningMessage = $('#warning_external_automatically_set'); - if (!$userExternal.prop('checked')) $userExternal.prop('checked', 'checked'); - }); - - describe('Behaviour of userExternal checkbox when', () => { - it('matches email as internal', (done) => { - expect($warningMessage.hasClass('hidden')).toBeTruthy(); - - $userEmail.val('test@').trigger('input'); - - expect($userExternal.prop('checked')).toBeFalsy(); - expect($warningMessage.hasClass('hidden')).toBeFalsy(); - done(); - }); - - it('matches email as external', (done) => { - expect($warningMessage.hasClass('hidden')).toBeTruthy(); - - $userEmail.val('test.ext@').trigger('input'); - - expect($userExternal.prop('checked')).toBeTruthy(); - expect($warningMessage.hasClass('hidden')).toBeTruthy(); - done(); - }); - }); -}); diff --git a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js index 694a0c2b9c1..2992c7f0624 100644 --- a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js +++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js @@ -1,7 +1,8 @@ -import { GlForm, GlFormInputGroup } from '@gitlab/ui'; +import { GlForm, GlFormInputGroup, GlFormInput } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import axios from 'axios'; import AxiosMockAdapter from 'axios-mock-adapter'; +import { kebabCase } from 'lodash'; import createFlash from '~/flash'; import httpStatus from '~/lib/utils/http_status'; import * as urlUtility from '~/lib/utils/url_utility'; @@ -59,6 +60,7 @@ describe('ForkForm component', () => { }, stubs: { GlFormInputGroup, + GlFormInput, }, }); }; @@ -204,6 +206,37 @@ describe('ForkForm component', () => { }); }); + describe('project slug', () => { + const projectPath = 'some other project slug'; + + beforeEach(() => { + mockGetRequest(); + createComponent({ + projectPath, + }); + }); + + it('initially loads slug without kebab-case transformation', () => { + expect(findForkSlugInput().attributes('value')).toBe(projectPath); + }); + + it('changes to kebab case when project name changes', async () => { + const newInput = `${projectPath}1`; + findForkNameInput().vm.$emit('input', newInput); + await wrapper.vm.$nextTick(); + + expect(findForkSlugInput().attributes('value')).toBe(kebabCase(newInput)); + }); + + it('does not change to kebab case when project slug is changed manually', async () => { + const newInput = `${projectPath}1`; + findForkSlugInput().vm.$emit('input', newInput); + await wrapper.vm.$nextTick(); + + expect(findForkSlugInput().attributes('value')).toBe(newInput); + }); + }); + describe('visibility level', () => { it.each` project | namespace | privateIsDisabled | internalIsDisabled | publicIsDisabled 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_a_spec.js.snap index 1c1327e7a4e..8b54a06ac7c 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_a_spec.js.snap @@ -1,70 +1,322 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Learn GitLab Design A should render the loading state 1`] = ` -<ul> - <li> - <span> - Create or import a repository - </span> - </li> - <li> - <span> - Invite your colleagues - </span> - </li> - <li> - <span> - <gl-link-stub - href="http://example.com/" +exports[`Learn GitLab Design A renders correctly 1`] = ` +<div> + <div + class="row" + > + <div + class="gl-mb-7 gl-ml-5" + > + <h1 + class="gl-font-size-h1" > - Set up CI/CD - </gl-link-stub> - </span> - </li> - <li> - <span> - <gl-link-stub - href="http://example.com/" + Learn GitLab + </h1> + + <p + class="gl-text-gray-700 gl-mb-0" > - Start a free Ultimate trial - </gl-link-stub> - </span> - </li> - <li> - <span> - <gl-link-stub - href="http://example.com/" + 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%;" > - Add code owners - </gl-link-stub> - </span> - </li> - <li> - <span> - <gl-link-stub - href="http://example.com/" + <!----> + </div> + </div> + </div> + + <div + class="row row-cols-1 row-cols-md-3 gl-mt-5" + > + <div + class="col gl-mb-6" + > + <div + class="gl-card gl-pt-0 learn-gitlab-section-card" > - Add merge request approval - </gl-link-stub> - </span> - </li> - <li> - <span> - <gl-link-stub - href="http://example.com/" + <!----> + + <div + class="gl-card-body" + > + <div + class="learn-gitlab-section-card-header" + > + <img + src="/assets/learn_gitlab/section_workspace.svg" + /> + + <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> + + <div + class="gl-mb-4" + > + <span + class="gl-text-green-500" + > + <svg + aria-hidden="true" + class="gl-icon s16" + data-testid="completed-icon" + > + <use + href="#check-circle-filled" + /> + </svg> + + Invite your colleagues + + </span> + + <!----> + </div> + <div + class="gl-mb-4" + > + <span + class="gl-text-green-500" + > + <svg + aria-hidden="true" + class="gl-icon s16" + data-testid="completed-icon" + > + <use + href="#check-circle-filled" + /> + </svg> + + Create or import a repository + + </span> + + <!----> + </div> + <div + class="gl-mb-4" + > + <span> + <a + class="gl-link" + href="http://example.com/" + > + Set up CI/CD + </a> + </span> + + <!----> + </div> + <div + class="gl-mb-4" + > + <span> + <a + class="gl-link" + href="http://example.com/" + > + Start a free Ultimate trial + </a> + </span> + + <!----> + </div> + <div + class="gl-mb-4" + > + <span> + <a + class="gl-link" + href="http://example.com/" + > + Add code owners + </a> + </span> + + <span + class="gl-font-style-italic gl-text-gray-500" + data-testid="trial-only" + > + + - Trial only + + </span> + </div> + <div + class="gl-mb-4" + > + <span> + <a + class="gl-link" + href="http://example.com/" + > + Add merge request approval + </a> + </span> + + <span + class="gl-font-style-italic gl-text-gray-500" + data-testid="trial-only" + > + + - Trial only + + </span> + </div> + </div> + + <!----> + </div> + </div> + <div + class="col gl-mb-6" + > + <div + class="gl-card gl-pt-0 learn-gitlab-section-card" > - Submit a merge request - </gl-link-stub> - </span> - </li> - <li> - <span> - <gl-link-stub - href="http://example.com/" + <!----> + + <div + class="gl-card-body" + > + <div + class="learn-gitlab-section-card-header" + > + <img + src="/assets/learn_gitlab/section_plan.svg" + /> + + <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> + + <div + class="gl-mb-4" + > + <span> + <a + class="gl-link" + href="http://example.com/" + > + Create an issue + </a> + </span> + + <!----> + </div> + <div + class="gl-mb-4" + > + <span> + <a + class="gl-link" + href="http://example.com/" + > + Submit a merge request + </a> + </span> + + <!----> + </div> + </div> + + <!----> + </div> + </div> + <div + class="col gl-mb-6" + > + <div + class="gl-card gl-pt-0 learn-gitlab-section-card" > - Run a security scan - </gl-link-stub> - </span> - </li> -</ul> + <!----> + + <div + class="gl-card-body" + > + <div + class="learn-gitlab-section-card-header" + > + <img + src="/assets/learn_gitlab/section_deploy.svg" + /> + + <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> + + <div + class="gl-mb-4" + > + <span> + <a + class="gl-link" + href="http://example.com/" + > + Run a Security scan using CI/CD + </a> + </span> + + <!----> + </div> + </div> + + <!----> + </div> + </div> + </div> +</div> `; 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 index dd899b93302..07c7f2df09e 100644 --- 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 @@ -29,21 +29,21 @@ exports[`Learn GitLab Design B renders correctly 1`] = ` class="gl-text-gray-500 gl-mb-2" data-testid="completion-percentage" > - 25% completed + 22% completed </p> <div class="progress" - max="8" + max="9" value="2" > <div - aria-valuemax="8" + aria-valuemax="9" aria-valuemin="0" aria-valuenow="2" class="progress-bar" role="progressbar" - style="width: 25%;" + style="width: 22.22222222222222%;" > <!----> </div> @@ -94,6 +94,7 @@ exports[`Learn GitLab Design B renders correctly 1`] = ` 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" /> @@ -151,6 +152,7 @@ exports[`Learn GitLab Design B renders correctly 1`] = ` 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" /> @@ -200,6 +202,7 @@ exports[`Learn GitLab Design B renders correctly 1`] = ` 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" /> @@ -249,6 +252,7 @@ exports[`Learn GitLab Design B renders correctly 1`] = ` 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" /> @@ -303,6 +307,7 @@ exports[`Learn GitLab Design B renders correctly 1`] = ` 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" /> @@ -357,6 +362,7 @@ exports[`Learn GitLab Design B renders correctly 1`] = ` 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" /> @@ -422,6 +428,57 @@ exports[`Learn GitLab Design B renders correctly 1`] = ` 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" + 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" /> @@ -487,11 +544,12 @@ exports[`Learn GitLab Design B renders correctly 1`] = ` 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 + Run a Security scan using CI/CD </h6> <p @@ -506,7 +564,7 @@ exports[`Learn GitLab Design B renders correctly 1`] = ` rel="noopener noreferrer" target="_blank" > - Run a Security scan + Run a Security scan using CI/CD </a> </div> </div> diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_section_card_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_section_card_spec.js.snap new file mode 100644 index 00000000000..ad8db0822cc --- /dev/null +++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_section_card_spec.js.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Learn GitLab Section Card renders correctly 1`] = ` +<gl-card-stub + bodyclass="" + class="gl-pt-0 learn-gitlab-section-card" + footerclass="" + headerclass="" +> + <div + class="learn-gitlab-section-card-header" + > + <img + src="/assets/learn_gitlab/section_workspace.svg" + /> + + <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> + + <learn-gitlab-section-link-stub + action="userAdded" + value="[object Object]" + /> + <learn-gitlab-section-link-stub + action="issueCreated" + value="[object Object]" + /> + <learn-gitlab-section-link-stub + action="gitWrite" + value="[object Object]" + /> + <learn-gitlab-section-link-stub + action="mergeRequestCreated" + value="[object Object]" + /> + <learn-gitlab-section-link-stub + action="securityScanEnabled" + value="[object Object]" + /> + <learn-gitlab-section-link-stub + action="pipelineCreated" + value="[object Object]" + /> + <learn-gitlab-section-link-stub + action="trialStarted" + value="[object Object]" + /> + <learn-gitlab-section-link-stub + action="codeOwnersEnabled" + value="[object Object]" + /> + <learn-gitlab-section-link-stub + action="requiredMrApprovalsEnabled" + value="[object Object]" + /> +</gl-card-stub> +`; 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_a_spec.js index 2154358de51..64ace341038 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_a_spec.js +++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_a_spec.js @@ -1,22 +1,38 @@ -import { shallowMount } from '@vue/test-utils'; +import { GlProgressBar } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; import LearnGitlabA from '~/pages/projects/learn_gitlab/components/learn_gitlab_a.vue'; import { testActions } from './mock_data'; describe('Learn GitLab Design A', () => { let wrapper; + const createWrapper = () => { + wrapper = mount(LearnGitlabA, { propsData: { actions: testActions } }); + }; + + beforeEach(() => { + createWrapper(); + }); + afterEach(() => { wrapper.destroy(); wrapper = null; }); - const createWrapper = () => { - wrapper = shallowMount(LearnGitlabA, { propsData: { actions: testActions } }); - }; + it('renders correctly', () => { + expect(wrapper.element).toMatchSnapshot(); + }); - it('should render the loading state', () => { - createWrapper(); + it('renders the progress percentage', () => { + const text = wrapper.find('[data-testid="completion-percentage"]').text(); - expect(wrapper.element).toMatchSnapshot(); + 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_b_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_b_spec.js index fbb989fae32..207944bfa1f 100644 --- 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 @@ -26,13 +26,13 @@ describe('Learn GitLab Design B', () => { it('renders the progress percentage', () => { const text = wrapper.find('[data-testid="completion-percentage"]').text(); - expect(text).toEqual('25% completed'); + expect(text).toBe('22% completed'); }); it('renders the progress bar with correct values', () => { - const progressBar = wrapper.find(GlProgressBar); + const progressBar = wrapper.findComponent(GlProgressBar); expect(progressBar.attributes('value')).toBe('2'); - expect(progressBar.attributes('max')).toBe('8'); + expect(progressBar.attributes('max')).toBe('9'); }); }); diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_card_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_card_spec.js new file mode 100644 index 00000000000..de6aca08235 --- /dev/null +++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_card_spec.js @@ -0,0 +1,26 @@ +import { shallowMount } from '@vue/test-utils'; +import LearnGitlabSectionCard from '~/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue'; +import { testActions } from './mock_data'; + +const defaultSection = 'workspace'; + +describe('Learn GitLab Section Card', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const createWrapper = () => { + wrapper = shallowMount(LearnGitlabSectionCard, { + propsData: { section: defaultSection, actions: testActions }, + }); + }; + + it('renders correctly', () => { + createWrapper({ completed: false }); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js new file mode 100644 index 00000000000..882d233a239 --- /dev/null +++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js @@ -0,0 +1,49 @@ +import { shallowMount } from '@vue/test-utils'; +import LearnGitlabSectionLink from '~/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue'; + +const defaultAction = 'gitWrite'; +const defaultProps = { + title: 'Create Repository', + description: 'Some description', + url: 'https://example.com', + completed: false, +}; + +describe('Learn GitLab Section Link', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const createWrapper = (action = defaultAction, props = {}) => { + wrapper = shallowMount(LearnGitlabSectionLink, { + propsData: { action, value: { ...defaultProps, ...props } }, + }); + }; + + it('renders no icon when not completed', () => { + createWrapper(undefined, { completed: false }); + + expect(wrapper.find('[data-testid="completed-icon"]').exists()).toBe(false); + }); + + it('renders the completion icon when completed', () => { + createWrapper(undefined, { completed: true }); + + expect(wrapper.find('[data-testid="completed-icon"]').exists()).toBe(true); + }); + + it('renders no trial only when it is not required', () => { + createWrapper(); + + expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(false); + }); + + it('renders trial only when trial is required', () => { + createWrapper('codeOwnersEnabled'); + + expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(true); + }); +}); diff --git a/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js b/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js index caac667e2b1..d6ee2b00c8e 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js +++ b/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js @@ -39,4 +39,9 @@ export const testActions = { completed: false, svg: 'http://example.com/images/illustration.svg', }, + issueCreated: { + url: 'http://example.com/', + completed: false, + svg: 'http://example.com/images/illustration.svg', + }, }; diff --git a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js index bee628c3a56..878721666ff 100644 --- a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js +++ b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js @@ -46,6 +46,7 @@ const defaultProps = { pagesHelpPath: '/help/user/project/pages/introduction#gitlab-pages-access-control', packagesAvailable: false, packagesHelpPath: '/help/user/packages/index', + requestCveAvailable: true, }; describe('Settings Panel', () => { @@ -76,6 +77,7 @@ describe('Settings Panel', () => { const findRepositoryFeatureSetting = () => findRepositoryFeatureProjectRow().find(projectFeatureSetting); const findProjectVisibilitySettings = () => wrapper.find({ ref: 'project-visibility-settings' }); + const findIssuesSettingsRow = () => wrapper.find({ ref: 'issues-settings' }); const findAnalyticsRow = () => wrapper.find({ ref: 'analytics-settings' }); const findProjectVisibilityLevelInput = () => wrapper.find('[name="project[visibility_level]"]'); const findRequestAccessEnabledInput = () => @@ -174,6 +176,16 @@ describe('Settings Panel', () => { }); }); + describe('Issues settings', () => { + it('has label for CVE request toggle', () => { + wrapper = mountComponent(); + + expect(findIssuesSettingsRow().findComponent(GlToggle).props('label')).toBe( + settingsPanel.i18n.cve_request_toggle_label, + ); + }); + }); + describe('Repository', () => { it('should set the repository help text when the visibility level is set to private', () => { wrapper = mountComponent({ currentSettings: { visibilityLevel: visibilityOptions.PRIVATE } }); @@ -228,7 +240,7 @@ describe('Settings Panel', () => { }); }); - describe('Pipelines', () => { + describe('CI/CD', () => { it('should enable the builds access level input when the repository is enabled', () => { wrapper = mountComponent({ currentSettings: { repositoryAccessLevel: featureAccessLevel.EVERYONE }, @@ -304,6 +316,17 @@ describe('Settings Panel', () => { expect(findContainerRegistryEnabledInput().props('disabled')).toBe(true); }); + + it('has label for the toggle', () => { + wrapper = mountComponent({ + currentSettings: { visibilityLevel: visibilityOptions.PUBLIC }, + registryAvailable: true, + }); + + expect(findContainerRegistrySettings().findComponent(GlToggle).props('label')).toBe( + settingsPanel.i18n.containerRegistryLabel, + ); + }); }); describe('Git Large File Storage', () => { @@ -342,6 +365,15 @@ describe('Settings Panel', () => { expect(findLFSFeatureToggle().props('disabled')).toBe(true); }); + it('has label for toggle', () => { + wrapper = mountComponent({ + currentSettings: { repositoryAccessLevel: featureAccessLevel.EVERYONE }, + lfsAvailable: true, + }); + + expect(findLFSFeatureToggle().props('label')).toBe(settingsPanel.i18n.lfsLabel); + }); + it('should not change lfsEnabled when disabling the repository', async () => { // mount over shallowMount, because we are aiming to test rendered state of toggle wrapper = mountComponent({ currentSettings: { lfsEnabled: true } }, mount); @@ -432,6 +464,17 @@ describe('Settings Panel', () => { expect(findPackagesEnabledInput().props('disabled')).toBe(true); }); + + it('has label for toggle', () => { + wrapper = mountComponent({ + currentSettings: { repositoryAccessLevel: featureAccessLevel.EVERYONE }, + packagesAvailable: true, + }); + + expect(findPackagesEnabledInput().findComponent(GlToggle).props('label')).toBe( + settingsPanel.i18n.packagesLabel, + ); + }); }); describe('Pages', () => { diff --git a/spec/frontend/pages/shared/wikis/wiki_alert_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_alert_spec.js index 6a18473b1a7..6a18473b1a7 100644 --- a/spec/frontend/pages/shared/wikis/wiki_alert_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_alert_spec.js diff --git a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js new file mode 100644 index 00000000000..8ab0b87d2ee --- /dev/null +++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js @@ -0,0 +1,222 @@ +import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import WikiForm from '~/pages/shared/wikis/components/wiki_form.vue'; + +describe('WikiForm', () => { + let wrapper; + + const findForm = () => wrapper.find('form'); + const findTitle = () => wrapper.find('#wiki_title'); + const findFormat = () => wrapper.find('#wiki_format'); + const findContent = () => wrapper.find('#wiki_content'); + const findMessage = () => wrapper.find('#wiki_message'); + const findSubmitButton = () => wrapper.findByTestId('wiki-submit-button'); + const findCancelButton = () => wrapper.findByTestId('wiki-cancel-button'); + const findTitleHelpLink = () => wrapper.findByTestId('wiki-title-help-link'); + const findMarkdownHelpLink = () => wrapper.findByTestId('wiki-markdown-help-link'); + + const pageInfoNew = { + persisted: false, + uploadsPath: '/project/path/-/wikis/attachments', + wikiPath: '/project/path/-/wikis', + helpPath: '/help/user/project/wiki/index', + markdownHelpPath: '/help/user/markdown', + markdownPreviewPath: '/project/path/-/wikis/.md/preview-markdown', + createPath: '/project/path/-/wikis/new', + }; + + const pageInfoPersisted = { + ...pageInfoNew, + persisted: true, + + title: 'My page', + content: 'My page content', + format: 'markdown', + path: '/project/path/-/wikis/home', + }; + + function createWrapper(persisted = false, pageInfo = {}) { + wrapper = extendedWrapper( + mount( + WikiForm, + { + provide: { + formatOptions: { + Markdown: 'markdown', + RDoc: 'rdoc', + AsciiDoc: 'asciidoc', + Org: 'org', + }, + pageInfo: { + ...(persisted ? pageInfoPersisted : pageInfoNew), + ...pageInfo, + }, + }, + }, + { attachToDocument: true }, + ), + ); + + jest.spyOn(wrapper.vm, 'onBeforeUnload'); + } + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it.each` + title | persisted | message + ${'my page'} | ${false} | ${'Create my page'} + ${'my-page'} | ${false} | ${'Create my page'} + ${'somedir/my-page'} | ${false} | ${'Create somedir/my page'} + ${'my-page'} | ${true} | ${'Update my page'} + `( + 'updates the commit message to $message when title is $title and persisted=$persisted', + async ({ title, message, persisted }) => { + createWrapper(persisted); + + findTitle().setValue(title); + + await wrapper.vm.$nextTick(); + + expect(findMessage().element.value).toBe(message); + }, + ); + + it('sets the commit message to "Update My page" when the page first loads when persisted', async () => { + createWrapper(true); + + await wrapper.vm.$nextTick(); + + expect(findMessage().element.value).toBe('Update My page'); + }); + + it.each` + value | text + ${'markdown'} | ${'[Link Title](page-slug)'} + ${'rdoc'} | ${'{Link title}[link:page-slug]'} + ${'asciidoc'} | ${'link:page-slug[Link title]'} + ${'org'} | ${'[[page-slug]]'} + `('updates the link help message when format=$value is selected', async ({ value, text }) => { + createWrapper(); + + findFormat().find(`option[value=${value}]`).setSelected(); + + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain(text); + }); + + it('starts with no unload warning', async () => { + createWrapper(); + + await wrapper.vm.$nextTick(); + + window.dispatchEvent(new Event('beforeunload')); + + expect(wrapper.vm.onBeforeUnload).not.toHaveBeenCalled(); + }); + + it.each` + persisted | titleHelpText | titleHelpLink + ${true} | ${'You can move this page by adding the path to the beginning of the title.'} | ${'/help/user/project/wiki/index#move-a-wiki-page'} + ${false} | ${'You can specify the full path for the new file. We will automatically create any missing directories.'} | ${'/help/user/project/wiki/index#create-a-new-wiki-page'} + `( + 'shows appropriate title help text and help link for when persisted=$persisted', + async ({ persisted, titleHelpLink, titleHelpText }) => { + createWrapper(persisted); + + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain(titleHelpText); + expect(findTitleHelpLink().attributes().href).toEqual(titleHelpLink); + }, + ); + + it('shows correct link for wiki specific markdown docs', async () => { + createWrapper(); + + await wrapper.vm.$nextTick(); + + expect(findMarkdownHelpLink().attributes().href).toEqual( + '/help/user/markdown#wiki-specific-markdown', + ); + }); + + describe('when wiki content is updated', () => { + beforeEach(() => { + createWrapper(); + + const input = findContent(); + input.setValue('Lorem ipsum dolar sit!'); + input.element.dispatchEvent(new Event('input')); + + return wrapper.vm.$nextTick(); + }); + + it('sets before unload warning', () => { + window.dispatchEvent(new Event('beforeunload')); + + expect(wrapper.vm.onBeforeUnload).toHaveBeenCalled(); + }); + + it('when form submitted, unsets before unload warning', async () => { + findForm().element.dispatchEvent(new Event('submit')); + + await wrapper.vm.$nextTick(); + + window.dispatchEvent(new Event('beforeunload')); + + expect(wrapper.vm.onBeforeUnload).not.toHaveBeenCalled(); + }); + }); + + describe('submit button state', () => { + it.each` + title | content | buttonState | disabledAttr + ${'something'} | ${'something'} | ${'enabled'} | ${undefined} + ${''} | ${'something'} | ${'disabled'} | ${'disabled'} + ${'something'} | ${''} | ${'disabled'} | ${'disabled'} + ${''} | ${''} | ${'disabled'} | ${'disabled'} + ${' '} | ${' '} | ${'disabled'} | ${'disabled'} + `( + "when title='$title', content='$content', then the button is $buttonState'", + async ({ title, content, disabledAttr }) => { + createWrapper(); + + findTitle().setValue(title); + findContent().setValue(content); + + await wrapper.vm.$nextTick(); + + expect(findSubmitButton().attributes().disabled).toBe(disabledAttr); + }, + ); + + it.each` + persisted | buttonLabel + ${true} | ${'Save changes'} + ${false} | ${'Create page'} + `('when persisted=$persisted, label is set to $buttonLabel', ({ persisted, buttonLabel }) => { + createWrapper(persisted); + + expect(findSubmitButton().text()).toBe(buttonLabel); + }); + }); + + describe('cancel button state', () => { + it.each` + persisted | redirectLink + ${false} | ${'/project/path/-/wikis'} + ${true} | ${'/project/path/-/wikis/home'} + `( + 'when persisted=$persisted, redirects the user to appropriate path', + ({ persisted, redirectLink }) => { + createWrapper(persisted); + + expect(findCancelButton().attributes().href).toEqual(redirectLink); + }, + ); + }); +}); diff --git a/spec/frontend/performance_bar/components/detailed_metric_spec.js b/spec/frontend/performance_bar/components/detailed_metric_spec.js index 6ddd047d549..c35bd772c86 100644 --- a/spec/frontend/performance_bar/components/detailed_metric_spec.js +++ b/spec/frontend/performance_bar/components/detailed_metric_spec.js @@ -1,24 +1,40 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { trimText } from 'helpers/text_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import DetailedMetric from '~/performance_bar/components/detailed_metric.vue'; import RequestWarning from '~/performance_bar/components/request_warning.vue'; +import { sortOrders } from '~/performance_bar/constants'; describe('detailedMetric', () => { let wrapper; - const createComponent = (props) => { - wrapper = shallowMount(DetailedMetric, { - propsData: { - ...props, - }, - }); + const defaultProps = { + currentRequest: {}, + metric: 'gitaly', + header: 'Gitaly calls', + keys: ['feature', 'request'], + }; + + const createComponent = (props = {}) => { + wrapper = extendedWrapper( + shallowMount(DetailedMetric, { + propsData: { ...defaultProps, ...props }, + }), + ); }; const findAllTraceBlocks = () => wrapper.findAll('pre'); const findTraceBlockAtIndex = (index) => findAllTraceBlocks().at(index); - const findExpandBacktraceBtns = () => wrapper.findAll('[data-testid="backtrace-expand-btn"]'); + const findExpandBacktraceBtns = () => wrapper.findAllByTestId('backtrace-expand-btn'); const findExpandedBacktraceBtnAtIndex = (index) => findExpandBacktraceBtns().at(index); + const findDetailsLabel = () => wrapper.findByTestId('performance-bar-details-label'); + const findSortOrderSwitcher = () => wrapper.findByTestId('performance-bar-sort-order'); + const findEmptyDetailNotice = () => wrapper.findByTestId('performance-bar-empty-detail-notice'); + const findAllDetailDurations = () => + wrapper.findAllByTestId('performance-item-duration').wrappers.map((w) => w.text()); + const findAllSummaryItems = () => + wrapper.findAllByTestId('performance-bar-summary-item').wrappers.map((w) => w.text()); afterEach(() => { wrapper.destroy(); @@ -26,13 +42,7 @@ describe('detailedMetric', () => { describe('when the current request has no details', () => { beforeEach(() => { - createComponent({ - currentRequest: {}, - metric: 'gitaly', - header: 'Gitaly calls', - details: 'details', - keys: ['feature', 'request'], - }); + createComponent(); }); it('does not render the element', () => { @@ -42,36 +52,171 @@ describe('detailedMetric', () => { describe('when the current request has details', () => { const requestDetails = [ - { duration: '100', feature: 'find_commit', request: 'abcdef', backtrace: ['hello', 'world'] }, { - duration: '23', + duration: 23, feature: 'rebase_in_progress', request: '', backtrace: ['other', 'example'], }, + { duration: 100, feature: 'find_commit', request: 'abcdef', backtrace: ['hello', 'world'] }, ]; - describe('with a default metric name', () => { + describe('with an empty detail', () => { + beforeEach(() => { + createComponent({ + currentRequest: { + details: { + gitaly: { + duration: '0ms', + calls: 0, + details: [], + warnings: [], + }, + }, + }, + }); + }); + + it('displays an empty title', () => { + expect(findDetailsLabel().text()).toBe('0'); + }); + + it('displays an empty modal', () => { + expect(findEmptyDetailNotice().text()).toContain('No gitaly calls for this request'); + }); + + it('does not display sort by switcher', () => { + expect(findSortOrderSwitcher().exists()).toBe(false); + }); + }); + + describe('when the details have a summary field', () => { beforeEach(() => { createComponent({ currentRequest: { details: { gitaly: { duration: '123ms', - calls: '456', + calls: 456, + details: requestDetails, + warnings: ['gitaly calls: 456 over 30'], + summary: { + 'In controllers': 100, + 'In middlewares': 20, + }, + }, + }, + }, + }); + }); + + it('displays a summary section', () => { + expect(findAllSummaryItems()).toEqual([ + 'Total 456', + 'Total duration 123ms', + 'In controllers 100', + 'In middlewares 20', + ]); + }); + }); + + describe('when the details have summaryOptions option', () => { + const gitalyDetails = { + duration: '123ms', + calls: 456, + details: requestDetails, + warnings: ['gitaly calls: 456 over 30'], + }; + + describe('when the details have summaryOptions > hideTotal option', () => { + beforeEach(() => { + createComponent({ + currentRequest: { + details: { + gitaly: { ...gitalyDetails, summaryOptions: { hideTotal: true } }, + }, + }, + }); + }); + + it('displays a summary section', () => { + expect(findAllSummaryItems()).toEqual(['Total duration 123ms']); + }); + }); + + describe('when the details have summaryOptions > hideDuration option', () => { + beforeEach(() => { + createComponent({ + currentRequest: { + details: { + gitaly: { ...gitalyDetails, summaryOptions: { hideDuration: true } }, + }, + }, + }); + }); + + it('displays a summary section', () => { + expect(findAllSummaryItems()).toEqual(['Total 456']); + }); + }); + + describe('when the details have both summary and summaryOptions field', () => { + beforeEach(() => { + createComponent({ + currentRequest: { + details: { + gitaly: { + ...gitalyDetails, + summary: { + 'In controllers': 100, + 'In middlewares': 20, + }, + summaryOptions: { + hideDuration: true, + hideTotal: true, + }, + }, + }, + }, + }); + }); + + it('displays a summary section', () => { + expect(findAllSummaryItems()).toEqual(['In controllers 100', 'In middlewares 20']); + }); + }); + }); + + describe("when the details don't have a start field", () => { + beforeEach(() => { + createComponent({ + currentRequest: { + details: { + gitaly: { + duration: '123ms', + calls: 456, details: requestDetails, warnings: ['gitaly calls: 456 over 30'], }, }, }, - metric: 'gitaly', - header: 'Gitaly calls', - keys: ['feature', 'request'], }); }); - it('displays details', () => { - expect(wrapper.text().replace(/\s+/g, ' ')).toContain('123ms / 456'); + it('displays details header', () => { + expect(findDetailsLabel().text()).toBe('123ms / 456'); + }); + + it('displays a basic summary section', () => { + expect(findAllSummaryItems()).toEqual(['Total 456', 'Total duration 123ms']); + }); + + it('sorts the details by descending duration order', () => { + expect(findAllDetailDurations()).toEqual(['100ms', '23ms']); + }); + + it('does not display sort by switcher', () => { + expect(findSortOrderSwitcher().exists()).toBe(false); }); it('adds a modal with a table of the details', () => { @@ -119,17 +264,75 @@ describe('detailedMetric', () => { findExpandedBacktraceBtnAtIndex(0).vm.$emit('click'); await nextTick(); expect(findAllTraceBlocks()).toHaveLength(1); - expect(findTraceBlockAtIndex(0).text()).toContain(requestDetails[0].backtrace[0]); + expect(findTraceBlockAtIndex(0).text()).toContain(requestDetails[1].backtrace[0]); secondExpandButton.vm.$emit('click'); await nextTick(); expect(findAllTraceBlocks()).toHaveLength(2); - expect(findTraceBlockAtIndex(1).text()).toContain(requestDetails[1].backtrace[0]); + expect(findTraceBlockAtIndex(1).text()).toContain(requestDetails[0].backtrace[0]); secondExpandButton.vm.$emit('click'); await nextTick(); expect(findAllTraceBlocks()).toHaveLength(1); - expect(findTraceBlockAtIndex(0).text()).toContain(requestDetails[0].backtrace[0]); + expect(findTraceBlockAtIndex(0).text()).toContain(requestDetails[1].backtrace[0]); + }); + }); + + describe('when the details have a start field', () => { + const requestDetailsWithStart = [ + { + start: '2021-03-18 11:41:49.846356 +0700', + duration: 23, + feature: 'rebase_in_progress', + request: '', + }, + { + start: '2021-03-18 11:42:11.645711 +0700', + duration: 75, + feature: 'find_commit', + request: 'abcdef', + }, + { + start: '2021-03-18 11:42:10.645711 +0700', + duration: 100, + feature: 'find_commit', + request: 'abcdef', + }, + ]; + + beforeEach(() => { + createComponent({ + currentRequest: { + details: { + gitaly: { + duration: '123ms', + calls: 456, + details: requestDetailsWithStart, + warnings: ['gitaly calls: 456 over 30'], + }, + }, + }, + metric: 'gitaly', + header: 'Gitaly calls', + keys: ['feature', 'request'], + }); + }); + + it('sorts the details by descending duration order', () => { + expect(findAllDetailDurations()).toEqual(['100ms', '75ms', '23ms']); + }); + + it('displays sort by switcher', () => { + expect(findSortOrderSwitcher().exists()).toBe(true); + }); + + it('allows switch sorting orders', async () => { + findSortOrderSwitcher().vm.$emit('input', sortOrders.CHRONOLOGICAL); + await nextTick(); + expect(findAllDetailDurations()).toEqual(['23ms', '100ms', '75ms']); + findSortOrderSwitcher().vm.$emit('input', sortOrders.DURATION); + await nextTick(); + expect(findAllDetailDurations()).toEqual(['100ms', '75ms', '23ms']); }); }); @@ -145,10 +348,7 @@ describe('detailedMetric', () => { }, }, }, - metric: 'gitaly', title: 'custom', - header: 'Gitaly calls', - keys: ['feature', 'request'], }); }); @@ -156,31 +356,39 @@ describe('detailedMetric', () => { expect(wrapper.text()).toContain('custom'); }); }); - }); - describe('when the details has no duration', () => { - beforeEach(() => { - createComponent({ - currentRequest: { - details: { - bullet: { - calls: '456', - details: [{ notification: 'notification', backtrace: 'backtrace' }], + describe('when the details has no duration', () => { + beforeEach(() => { + createComponent({ + currentRequest: { + details: { + bullet: { + calls: '456', + details: [{ notification: 'notification', backtrace: 'backtrace' }], + }, }, }, - }, - metric: 'bullet', - header: 'Bullet notifications', - keys: ['notification'], + metric: 'bullet', + header: 'Bullet notifications', + keys: ['notification'], + }); }); - }); - it('renders only the number of calls', async () => { - expect(trimText(wrapper.text())).toEqual('456 notification bullet'); + it('displays calls in the label', () => { + expect(findDetailsLabel().text()).toBe('456'); + }); + + it('displays a basic summary section', () => { + expect(findAllSummaryItems()).toEqual(['Total 456']); + }); + + it('renders only the number of calls', async () => { + expect(trimText(wrapper.text())).toContain('notification bullet'); - findExpandedBacktraceBtnAtIndex(0).vm.$emit('click'); - await nextTick(); - expect(trimText(wrapper.text())).toEqual('456 notification backtrace bullet'); + findExpandedBacktraceBtnAtIndex(0).vm.$emit('click'); + await nextTick(); + expect(trimText(wrapper.text())).toContain('notification backtrace bullet'); + }); }); }); }); diff --git a/spec/frontend/performance_bar/stores/performance_bar_store_spec.js b/spec/frontend/performance_bar/stores/performance_bar_store_spec.js index 94dc1237cb0..b7324ba2f6e 100644 --- a/spec/frontend/performance_bar/stores/performance_bar_store_spec.js +++ b/spec/frontend/performance_bar/stores/performance_bar_store_spec.js @@ -59,4 +59,44 @@ describe('PerformanceBarStore', () => { expect(store.findRequest('id').details.test.calls).toEqual(123); }); }); + + describe('canTrackRequest', () => { + let store; + + beforeEach(() => { + store = new PerformanceBarStore(); + }); + + it('limits to 10 requests for GraphQL', () => { + expect(store.canTrackRequest('https://gitlab.com/api/graphql')).toBe(true); + + store.addRequest('0', 'https://gitlab.com/api/graphql'); + store.addRequest('1', 'https://gitlab.com/api/graphql'); + store.addRequest('2', 'https://gitlab.com/api/graphql'); + store.addRequest('3', 'https://gitlab.com/api/graphql'); + store.addRequest('4', 'https://gitlab.com/api/graphql'); + store.addRequest('5', 'https://gitlab.com/api/graphql'); + store.addRequest('6', 'https://gitlab.com/api/graphql'); + store.addRequest('7', 'https://gitlab.com/api/graphql'); + store.addRequest('8', 'https://gitlab.com/api/graphql'); + + expect(store.canTrackRequest('https://gitlab.com/api/graphql')).toBe(true); + + store.addRequest('9', 'https://gitlab.com/api/graphql'); + + expect(store.canTrackRequest('https://gitlab.com/api/graphql')).toBe(false); + }); + + it('limits to 2 requests for all other URLs', () => { + expect(store.canTrackRequest('https://gitlab.com/api/v4/users/1')).toBe(true); + + store.addRequest('a', 'https://gitlab.com/api/v4/users/1'); + + expect(store.canTrackRequest('https://gitlab.com/api/v4/users/1')).toBe(true); + + store.addRequest('b', 'https://gitlab.com/api/v4/users/1'); + + expect(store.canTrackRequest('https://gitlab.com/api/v4/users/1')).toBe(false); + }); + }); }); diff --git a/spec/frontend/pipeline_editor/components/code_snippet_alert/code_snippet_alert_spec.js b/spec/frontend/pipeline_editor/components/code_snippet_alert/code_snippet_alert_spec.js new file mode 100644 index 00000000000..d03f12bc249 --- /dev/null +++ b/spec/frontend/pipeline_editor/components/code_snippet_alert/code_snippet_alert_spec.js @@ -0,0 +1,61 @@ +import { within } from '@testing-library/dom'; +import { mount } from '@vue/test-utils'; +import { merge } from 'lodash'; +import { TEST_HOST } from 'helpers/test_constants'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import CodeSnippetAlert from '~/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue'; +import { CODE_SNIPPET_SOURCE_API_FUZZING } from '~/pipeline_editor/components/code_snippet_alert/constants'; + +const apiFuzzingConfigurationPath = '/namespace/project/-/security/configuration/api_fuzzing'; + +describe('EE - CodeSnippetAlert', () => { + let wrapper; + + const createWrapper = (options) => { + wrapper = extendedWrapper( + mount( + CodeSnippetAlert, + merge( + { + provide: { + configurationPaths: { + [CODE_SNIPPET_SOURCE_API_FUZZING]: apiFuzzingConfigurationPath, + }, + }, + propsData: { + source: CODE_SNIPPET_SOURCE_API_FUZZING, + }, + }, + options, + ), + ), + ); + }; + + const withinComponent = () => within(wrapper.element); + const findDocsLink = () => withinComponent().getByRole('link', { name: /read documentation/i }); + const findConfigurationLink = () => + withinComponent().getByRole('link', { name: /Go back to configuration/i }); + + beforeEach(() => { + createWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it("provides a link to the feature's documentation", () => { + const docsLink = findDocsLink(); + + expect(docsLink).not.toBe(null); + expect(docsLink.href).toBe(`${TEST_HOST}/help/user/application_security/api_fuzzing/index`); + }); + + it("provides a link to the feature's configuration form", () => { + const configurationLink = findConfigurationLink(); + + expect(configurationLink).not.toBe(null); + expect(configurationLink.href).toBe(TEST_HOST + apiFuzzingConfigurationPath); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/editor/ci_config_merged_preview_spec.js b/spec/frontend/pipeline_editor/components/editor/ci_config_merged_preview_spec.js index 866069f337b..fb191fccb0d 100644 --- a/spec/frontend/pipeline_editor/components/editor/ci_config_merged_preview_spec.js +++ b/spec/frontend/pipeline_editor/components/editor/ci_config_merged_preview_spec.js @@ -1,10 +1,8 @@ -import { GlAlert, GlIcon } from '@gitlab/ui'; +import { GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { EDITOR_READY_EVENT } from '~/editor/constants'; import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue'; -import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants'; -import { INVALID_CI_CONFIG } from '~/pipelines/constants'; import { mockLintResponse, mockCiConfigPath } from '../../mock_data'; describe('Text editor component', () => { @@ -33,28 +31,11 @@ describe('Text editor component', () => { }); }; - const findAlert = () => wrapper.findComponent(GlAlert); const findIcon = () => wrapper.findComponent(GlIcon); const findEditor = () => wrapper.findComponent(MockEditorLite); afterEach(() => { wrapper.destroy(); - wrapper = null; - }); - - describe('when status is invalid', () => { - beforeEach(() => { - createComponent({ props: { ciConfigData: { status: CI_CONFIG_STATUS_INVALID } } }); - }); - - it('show an error message', () => { - expect(findAlert().exists()).toBe(true); - expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[INVALID_CI_CONFIG]); - }); - - it('hides the editor', () => { - expect(findEditor().exists()).toBe(false); - }); }); describe('when status is valid', () => { 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 new file mode 100644 index 00000000000..fa937100982 --- /dev/null +++ b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js @@ -0,0 +1,123 @@ +import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import BranchSwitcher from '~/pipeline_editor/components/file_nav/branch_switcher.vue'; +import { DEFAULT_FAILURE } from '~/pipeline_editor/constants'; +import { mockDefaultBranch, mockProjectBranches, mockProjectFullPath } from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('Pipeline editor branch switcher', () => { + let wrapper; + let mockApollo; + let mockAvailableBranchQuery; + + const createComponentWithApollo = () => { + const resolvers = { + Query: { + project: mockAvailableBranchQuery, + }, + }; + + mockApollo = createMockApollo([], resolvers); + wrapper = shallowMount(BranchSwitcher, { + localVue, + apolloProvider: mockApollo, + provide: { + projectFullPath: mockProjectFullPath, + }, + data() { + return { + currentBranch: mockDefaultBranch, + }; + }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownItems = () => wrapper.findAll(GlDropdownItem); + + beforeEach(() => { + mockAvailableBranchQuery = jest.fn(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('while querying', () => { + beforeEach(() => { + createComponentWithApollo(); + }); + + it('does not render dropdown', () => { + expect(findDropdown().exists()).toBe(false); + }); + }); + + describe('after querying', () => { + beforeEach(async () => { + mockAvailableBranchQuery.mockResolvedValue(mockProjectBranches); + createComponentWithApollo(); + await waitForPromises(); + }); + + it('query is called with correct variables', async () => { + expect(mockAvailableBranchQuery).toHaveBeenCalledTimes(1); + expect(mockAvailableBranchQuery).toHaveBeenCalledWith( + expect.anything(), + { + fullPath: mockProjectFullPath, + }, + expect.anything(), + expect.anything(), + ); + }); + + it('renders list of branches', () => { + expect(findDropdown().exists()).toBe(true); + expect(findDropdownItems()).toHaveLength(mockProjectBranches.repository.branches.length); + }); + + it('renders current branch at the top of the list with a check mark', () => { + const firstDropdownItem = findDropdownItems().at(0); + const icon = firstDropdownItem.findComponent(GlIcon); + + expect(firstDropdownItem.text()).toBe(mockDefaultBranch); + expect(icon.exists()).toBe(true); + expect(icon.props('name')).toBe('check'); + }); + + it('does not render check mark for other branches', () => { + const secondDropdownItem = findDropdownItems().at(1); + const icon = secondDropdownItem.findComponent(GlIcon); + + expect(icon.classes()).toContain('gl-visibility-hidden'); + }); + }); + + describe('on fetch error', () => { + beforeEach(async () => { + mockAvailableBranchQuery.mockResolvedValue(new Error()); + createComponentWithApollo(); + await waitForPromises(); + }); + + it('does not render dropdown', () => { + expect(findDropdown().exists()).toBe(false); + }); + + it('shows an error message', () => { + expect(wrapper.emitted('showError')).toBeDefined(); + expect(wrapper.emitted('showError')[0]).toEqual([ + { + reasons: [wrapper.vm.$options.i18n.fetchError], + type: DEFAULT_FAILURE, + }, + ]); + }); + }); +}); 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 new file mode 100644 index 00000000000..94a0a7d14ee --- /dev/null +++ b/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js @@ -0,0 +1,49 @@ +import { shallowMount } from '@vue/test-utils'; +import BranchSwitcher from '~/pipeline_editor/components/file_nav/branch_switcher.vue'; +import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; + +describe('Pipeline editor file nav', () => { + let wrapper; + const mockProvide = { + glFeatures: { + pipelineEditorBranchSwitcher: true, + }, + }; + + const createComponent = ({ provide = {} } = {}) => { + wrapper = shallowMount(PipelineEditorFileNav, { + provide: { + ...mockProvide, + ...provide, + }, + }); + }; + + const findBranchSwitcher = () => wrapper.findComponent(BranchSwitcher); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the branch switcher', () => { + 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_editor_header_spec.js b/spec/frontend/pipeline_editor/components/header/pipeline_editor_header_spec.js index ef8ca574e59..27652bb268b 100644 --- a/spec/frontend/pipeline_editor/components/header/pipeline_editor_header_spec.js +++ b/spec/frontend/pipeline_editor/components/header/pipeline_editor_header_spec.js @@ -13,7 +13,7 @@ describe('Pipeline editor header', () => { }, }; - const createComponent = ({ provide = {} } = {}) => { + const createComponent = ({ provide = {}, props = {} } = {}) => { wrapper = shallowMount(PipelineEditorHeader, { provide: { ...mockProvide, @@ -23,6 +23,8 @@ describe('Pipeline editor header', () => { ciConfigData: mockLintResponse, ciFileContent: mockCiYml, isCiConfigDataLoading: false, + isNewCiConfigFile: false, + ...props, }, }); }; @@ -36,15 +38,21 @@ describe('Pipeline editor header', () => { }); describe('template', () => { - beforeEach(() => { - createComponent(); + it('hides the pipeline status for new projects without a CI file', () => { + createComponent({ props: { isNewCiConfigFile: true } }); + + expect(findPipelineStatus().exists()).toBe(false); }); - it('renders the pipeline status', () => { + it('renders the pipeline status when CI file exists', () => { + createComponent({ props: { isNewCiConfigFile: false } }); + expect(findPipelineStatus().exists()).toBe(true); }); it('renders the validation segment', () => { + createComponent(); + expect(findValidationSegment().exists()).toBe(true); }); }); 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 de6e112866b..b6d49d0d0f8 100644 --- a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js +++ b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js @@ -4,6 +4,7 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import PipelineStatus, { i18n } from '~/pipeline_editor/components/header/pipeline_status.vue'; +import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { mockCommitSha, mockProjectPipeline, mockProjectFullPath } from '../../mock_data'; @@ -19,32 +20,9 @@ describe('Pipeline Status', () => { let mockApollo; let mockPipelineQuery; - const createComponent = ({ hasPipeline = true, isQueryLoading = false }) => { - const pipeline = hasPipeline - ? { loading: isQueryLoading, ...mockProjectPipeline.pipeline } - : { loading: isQueryLoading }; - - wrapper = shallowMount(PipelineStatus, { - provide: mockProvide, - stubs: { GlLink, GlSprintf }, - data: () => (hasPipeline ? { pipeline } : {}), - mocks: { - $apollo: { - queries: { - pipeline, - }, - }, - }, - }); - }; - const createComponentWithApollo = () => { - const resolvers = { - Query: { - project: mockPipelineQuery, - }, - }; - mockApollo = createMockApollo([], resolvers); + const handlers = [[getPipelineQuery, mockPipelineQuery]]; + mockApollo = createMockApollo(handlers); wrapper = shallowMount(PipelineStatus, { localVue, @@ -78,16 +56,17 @@ describe('Pipeline Status', () => { wrapper = null; }); - describe('while querying', () => { - it('renders loading icon', () => { - createComponent({ isQueryLoading: true, hasPipeline: false }); + describe('loading icon', () => { + it('renders while query is being fetched', () => { + createComponentWithApollo(); expect(findLoadingIcon().exists()).toBe(true); expect(findPipelineLoadingMsg().text()).toBe(i18n.fetchLoading); }); - it('does not render loading icon if pipeline data is already set', () => { - createComponent({ isQueryLoading: true }); + it('does not render if query is no longer loading', async () => { + createComponentWithApollo(); + await waitForPromises(); expect(findLoadingIcon().exists()).toBe(false); }); @@ -96,7 +75,9 @@ describe('Pipeline Status', () => { describe('when querying data', () => { describe('when data is set', () => { beforeEach(async () => { - mockPipelineQuery.mockResolvedValue(mockProjectPipeline); + mockPipelineQuery.mockResolvedValue({ + data: { project: mockProjectPipeline }, + }); createComponentWithApollo(); await waitForPromises(); @@ -104,14 +85,10 @@ describe('Pipeline Status', () => { it('query is called with correct variables', async () => { expect(mockPipelineQuery).toHaveBeenCalledTimes(1); - expect(mockPipelineQuery).toHaveBeenCalledWith( - expect.anything(), - { - fullPath: mockProjectFullPath, - }, - expect.anything(), - expect.anything(), - ); + expect(mockPipelineQuery).toHaveBeenCalledWith({ + fullPath: mockProjectFullPath, + sha: mockCommitSha, + }); }); it('does not render error', () => { diff --git a/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js b/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js index 274c2d1b8da..fd8a100bb2c 100644 --- a/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js +++ b/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js @@ -6,13 +6,19 @@ import { sprintf } from '~/locale'; import ValidationSegment, { i18n, } from '~/pipeline_editor/components/header/validation_segment.vue'; -import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants'; +import { + CI_CONFIG_STATUS_INVALID, + EDITOR_APP_STATUS_EMPTY, + EDITOR_APP_STATUS_INVALID, + EDITOR_APP_STATUS_LOADING, + EDITOR_APP_STATUS_VALID, +} from '~/pipeline_editor/constants'; import { mockYmlHelpPagePath, mergeUnwrappedCiConfig, mockCiYml } from '../../mock_data'; describe('Validation segment component', () => { let wrapper; - const createComponent = (props = {}) => { + const createComponent = ({ props = {}, appStatus }) => { wrapper = extendedWrapper( shallowMount(ValidationSegment, { provide: { @@ -21,9 +27,14 @@ describe('Validation segment component', () => { propsData: { ciConfig: mergeUnwrappedCiConfig(), ciFileContent: mockCiYml, - loading: false, ...props, }, + // Simulate graphQL client query result + data() { + return { + appStatus, + }; + }, }), ); }; @@ -34,18 +45,17 @@ describe('Validation segment component', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); it('shows the loading state', () => { - createComponent({ loading: true }); + createComponent({ appStatus: EDITOR_APP_STATUS_LOADING }); expect(wrapper.text()).toBe(i18n.loading); }); describe('when config is empty', () => { beforeEach(() => { - createComponent({ ciFileContent: '' }); + createComponent({ appStatus: EDITOR_APP_STATUS_EMPTY }); }); it('has check icon', () => { @@ -59,7 +69,7 @@ describe('Validation segment component', () => { describe('when config is valid', () => { beforeEach(() => { - createComponent({}); + createComponent({ appStatus: EDITOR_APP_STATUS_VALID }); }); it('has check icon', () => { @@ -79,12 +89,9 @@ describe('Validation segment component', () => { describe('when config is invalid', () => { beforeEach(() => { createComponent({ - ciConfig: mergeUnwrappedCiConfig({ - status: CI_CONFIG_STATUS_INVALID, - }), + appStatus: EDITOR_APP_STATUS_INVALID, }); }); - it('has warning icon', () => { expect(findIcon().props('name')).toBe('warning-solid'); }); @@ -93,43 +100,53 @@ describe('Validation segment component', () => { expect(findValidationMsg().text()).toBe(i18n.invalid); }); - it('shows an invalid state with an error', () => { + it('shows the learn more link', () => { + expect(findLearnMoreLink().attributes('href')).toBe(mockYmlHelpPagePath); + expect(findLearnMoreLink().text()).toBe('Learn more'); + }); + + describe('with multiple errors', () => { const firstError = 'First Error'; const secondError = 'Second Error'; - createComponent({ - ciConfig: mergeUnwrappedCiConfig({ - status: CI_CONFIG_STATUS_INVALID, - errors: [firstError, secondError], - }), + beforeEach(() => { + createComponent({ + props: { + ciConfig: mergeUnwrappedCiConfig({ + status: CI_CONFIG_STATUS_INVALID, + errors: [firstError, secondError], + }), + }, + }); + }); + it('shows an invalid state with an error', () => { + // Test the error is shown _and_ the string matches + expect(findValidationMsg().text()).toContain(firstError); + expect(findValidationMsg().text()).toBe( + sprintf(i18n.invalidWithReason, { reason: firstError }), + ); }); - - // Test the error is shown _and_ the string matches - expect(findValidationMsg().text()).toContain(firstError); - expect(findValidationMsg().text()).toBe( - sprintf(i18n.invalidWithReason, { reason: firstError }), - ); }); - it('shows an invalid state with an error while preventing XSS', () => { + describe('with XSS inside the error', () => { const evilError = '<script>evil();</script>'; - createComponent({ - ciConfig: mergeUnwrappedCiConfig({ - status: CI_CONFIG_STATUS_INVALID, - errors: [evilError], - }), + beforeEach(() => { + createComponent({ + props: { + ciConfig: mergeUnwrappedCiConfig({ + status: CI_CONFIG_STATUS_INVALID, + errors: [evilError], + }), + }, + }); }); + it('shows an invalid state with an error while preventing XSS', () => { + const { innerHTML } = findValidationMsg().element; - const { innerHTML } = findValidationMsg().element; - - expect(innerHTML).not.toContain(evilError); - expect(innerHTML).toContain(escape(evilError)); - }); - - it('shows the learn more link', () => { - expect(findLearnMoreLink().attributes('href')).toBe(mockYmlHelpPagePath); - expect(findLearnMoreLink().text()).toBe('Learn more'); + expect(innerHTML).not.toContain(evilError); + expect(innerHTML).toContain(escape(evilError)); + }); }); }); }); diff --git a/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js b/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js index 6775433deb9..5fc0880b09e 100644 --- a/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js +++ b/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js @@ -7,7 +7,7 @@ import { mockJobs, mockErrors, mockWarnings } from '../../mock_data'; describe('CI Lint Results', () => { let wrapper; const defaultProps = { - valid: true, + isValid: true, jobs: mockJobs, errors: [], warnings: [], @@ -42,7 +42,6 @@ describe('CI Lint Results', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('Empty results', () => { @@ -72,7 +71,7 @@ describe('CI Lint Results', () => { describe('Invalid results', () => { beforeEach(() => { - createComponent({ valid: false, errors: mockErrors, warnings: mockWarnings }, mount); + createComponent({ isValid: false, errors: mockErrors, warnings: mockWarnings }, mount); }); it('does not display the table', () => { diff --git a/spec/frontend/pipeline_editor/components/lint/ci_lint_spec.js b/spec/frontend/pipeline_editor/components/lint/ci_lint_spec.js index fdddca3d62b..238942a34ff 100644 --- a/spec/frontend/pipeline_editor/components/lint/ci_lint_spec.js +++ b/spec/frontend/pipeline_editor/components/lint/ci_lint_spec.js @@ -1,13 +1,12 @@ import { GlAlert, GlLink } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; +import { mount, shallowMount } from '@vue/test-utils'; import CiLint from '~/pipeline_editor/components/lint/ci_lint.vue'; -import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants'; import { mergeUnwrappedCiConfig, mockLintHelpPagePath } from '../../mock_data'; describe('~/pipeline_editor/components/lint/ci_lint.vue', () => { let wrapper; - const createComponent = (props = {}, mountFn = shallowMount) => { + const createComponent = ({ props, mountFn = shallowMount } = {}) => { wrapper = mountFn(CiLint, { provide: { lintHelpPagePath: mockLintHelpPagePath, @@ -27,12 +26,11 @@ describe('~/pipeline_editor/components/lint/ci_lint.vue', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('Valid Results', () => { beforeEach(() => { - createComponent({}, mount); + createComponent({ props: { isValid: true }, mountFn: mount }); }); it('displays valid results', () => { @@ -66,14 +64,7 @@ describe('~/pipeline_editor/components/lint/ci_lint.vue', () => { }); it('displays invalid results', () => { - createComponent( - { - ciConfig: mergeUnwrappedCiConfig({ - status: CI_CONFIG_STATUS_INVALID, - }), - }, - mount, - ); + createComponent({ props: { isValid: false }, mountFn: mount }); expect(findAlert().text()).toMatch('Status: Syntax is incorrect.'); }); diff --git a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js index 24af17e9ce6..eba853180cd 100644 --- a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js +++ b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js @@ -4,8 +4,15 @@ import { nextTick } from 'vue'; import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue'; import CiLint from '~/pipeline_editor/components/lint/ci_lint.vue'; import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue'; +import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue'; +import { + EDITOR_APP_STATUS_EMPTY, + EDITOR_APP_STATUS_ERROR, + EDITOR_APP_STATUS_LOADING, + EDITOR_APP_STATUS_INVALID, + EDITOR_APP_STATUS_VALID, +} from '~/pipeline_editor/constants'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; - import { mockLintResponse, mockCiYml } from '../mock_data'; describe('Pipeline editor tabs component', () => { @@ -20,17 +27,27 @@ describe('Pipeline editor tabs component', () => { }, }; - const createComponent = ({ props = {}, provide = {}, mountFn = shallowMount } = {}) => { + const createComponent = ({ + props = {}, + provide = {}, + appStatus = EDITOR_APP_STATUS_VALID, + mountFn = shallowMount, + } = {}) => { wrapper = mountFn(PipelineEditorTabs, { propsData: { ciConfigData: mockLintResponse, ciFileContent: mockCiYml, - isCiConfigDataLoading: false, ...props, }, + data() { + return { + appStatus, + }; + }, provide: { ...mockProvide, ...provide }, stubs: { TextEditor: MockTextEditor, + EditorTab, }, }); }; @@ -49,7 +66,6 @@ describe('Pipeline editor tabs component', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('editor tab', () => { @@ -69,7 +85,7 @@ describe('Pipeline editor tabs component', () => { describe('with feature flag on', () => { describe('while loading', () => { beforeEach(() => { - createComponent({ props: { isCiConfigDataLoading: true } }); + createComponent({ appStatus: EDITOR_APP_STATUS_LOADING }); }); it('displays a loading icon if the lint query is loading', () => { @@ -108,7 +124,7 @@ describe('Pipeline editor tabs component', () => { describe('lint tab', () => { describe('while loading', () => { beforeEach(() => { - createComponent({ props: { isCiConfigDataLoading: true } }); + createComponent({ appStatus: EDITOR_APP_STATUS_LOADING }); }); it('displays a loading icon if the lint query is loading', () => { @@ -135,7 +151,7 @@ describe('Pipeline editor tabs component', () => { describe('with feature flag on', () => { describe('while loading', () => { beforeEach(() => { - createComponent({ props: { isCiConfigDataLoading: true } }); + createComponent({ appStatus: EDITOR_APP_STATUS_LOADING }); }); it('displays a loading icon if the lint query is loading', () => { @@ -143,9 +159,9 @@ describe('Pipeline editor tabs component', () => { }); }); - describe('when `mergedYaml` is undefined', () => { + describe('when there is a fetch error', () => { beforeEach(() => { - createComponent({ props: { ciConfigData: {} } }); + createComponent({ appStatus: EDITOR_APP_STATUS_ERROR }); }); it('show an error message', () => { @@ -180,4 +196,24 @@ describe('Pipeline editor tabs component', () => { }); }); }); + + describe('show tab content based on status', () => { + it.each` + appStatus | editor | viz | lint | merged + ${undefined} | ${true} | ${true} | ${true} | ${true} + ${EDITOR_APP_STATUS_EMPTY} | ${true} | ${false} | ${false} | ${false} + ${EDITOR_APP_STATUS_INVALID} | ${true} | ${false} | ${true} | ${false} + ${EDITOR_APP_STATUS_VALID} | ${true} | ${true} | ${true} | ${true} + `( + 'when status is $appStatus, we show - editor:$editor | viz:$viz | lint:$lint | merged:$merged ', + ({ appStatus, editor, viz, lint, merged }) => { + createComponent({ appStatus }); + + expect(findTextEditor().exists()).toBe(editor); + expect(findPipelineGraph().exists()).toBe(viz); + expect(findCiLint().exists()).toBe(lint); + expect(findMergedPreview().exists()).toBe(merged); + }, + ); + }); }); diff --git a/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js b/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js index 291468c5229..8def83d578b 100644 --- a/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js +++ b/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js @@ -1,12 +1,15 @@ -import { GlTabs } from '@gitlab/ui'; +import { GlAlert, GlTabs } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; - import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue'; const mockContent1 = 'MOCK CONTENT 1'; const mockContent2 = 'MOCK CONTENT 2'; +const MockEditorLite = { + template: '<div>EDITOR</div>', +}; + describe('~/pipeline_editor/components/ui/editor_tab.vue', () => { let wrapper; let mockChildMounted = jest.fn(); @@ -37,22 +40,34 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => { `, }; - const createWrapper = () => { + const createMockedWrapper = () => { wrapper = mount(MockTabbedContent); }; + const createWrapper = ({ props } = {}) => { + wrapper = mount(EditorTab, { + propsData: props, + slots: { + default: MockEditorLite, + }, + }); + }; + + const findSlotComponent = () => wrapper.findComponent(MockEditorLite); + const findAlert = () => wrapper.findComponent(GlAlert); + beforeEach(() => { mockChildMounted = jest.fn(); }); it('tabs are mounted lazily', async () => { - createWrapper(); + createMockedWrapper(); expect(mockChildMounted).toHaveBeenCalledTimes(0); }); it('first tab is only mounted after nextTick', async () => { - createWrapper(); + createMockedWrapper(); await nextTick(); @@ -60,6 +75,36 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => { expect(mockChildMounted).toHaveBeenCalledWith(mockContent1); }); + describe('showing the tab content depending on `isEmpty` and `isInvalid`', () => { + it.each` + isEmpty | isInvalid | showSlotComponent | text + ${undefined} | ${undefined} | ${true} | ${'renders'} + ${false} | ${false} | ${true} | ${'renders'} + ${undefined} | ${true} | ${false} | ${'hides'} + ${true} | ${false} | ${false} | ${'hides'} + ${false} | ${true} | ${false} | ${'hides'} + `( + '$text the slot component when isEmpty:$isEmpty and isInvalid:$isInvalid', + ({ isEmpty, isInvalid, showSlotComponent }) => { + createWrapper({ + props: { isEmpty, isInvalid }, + }); + expect(findSlotComponent().exists()).toBe(showSlotComponent); + expect(findAlert().exists()).toBe(!showSlotComponent); + }, + ); + + it('can have a custom empty message', () => { + const text = 'my custom alert message'; + createWrapper({ props: { isEmpty: true, emptyMessage: text } }); + + const alert = findAlert(); + + expect(alert.exists()).toBe(true); + expect(alert.text()).toBe(text); + }); + }); + describe('user interaction', () => { const clickTab = async (testid) => { wrapper.find(`[data-testid="${testid}"]`).trigger('click'); @@ -67,7 +112,7 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => { }; beforeEach(() => { - createWrapper(); + createMockedWrapper(); }); it('mounts a tab once after selecting it', async () => { diff --git a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js index 196a4133eea..f0932fc55d3 100644 --- a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js +++ b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js @@ -9,6 +9,7 @@ import { mockDefaultBranch, mockLintResponse, mockProjectFullPath, + mockProjectBranches, } from '../mock_data'; jest.mock('~/api', () => { @@ -47,21 +48,20 @@ describe('~/pipeline_editor/graphql/resolvers', () => { }); }); - describe('pipeline', () => { - it('resolves pipeline data with type names', async () => { - const result = await resolvers.Query.project(null); + describe('project', () => { + it('resolves project data with type names', async () => { + const result = await resolvers.Query.project(); // eslint-disable-next-line no-underscore-dangle expect(result.__typename).toBe('Project'); }); - it('resolves pipeline data with necessary data', async () => { - const result = await resolvers.Query.project(null); - const pipelineKeys = Object.keys(result.pipeline); - const statusKeys = Object.keys(result.pipeline.detailedStatus); + it('resolves project with available list of branches', async () => { + const result = await resolvers.Query.project(); - expect(pipelineKeys).toContain('id', 'commitPath', 'detailedStatus', 'shortSha'); - expect(statusKeys).toContain('detailsPath', 'text'); + expect(result.repository.branches).toHaveLength( + mockProjectBranches.repository.branches.length, + ); }); }); }); diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index 16d5ba0e714..7f651a42231 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -138,6 +138,20 @@ export const mergeUnwrappedCiConfig = (mergedConfig) => { }; }; +export const mockProjectBranches = { + __typename: 'Project', + repository: { + __typename: 'Repository', + branches: [ + { __typename: 'Branch', name: 'master' }, + { __typename: 'Branch', name: 'main' }, + { __typename: 'Branch', name: 'develop' }, + { __typename: 'Branch', name: 'production' }, + { __typename: 'Branch', name: 'test' }, + ], + }, +}; + export const mockProjectPipeline = { pipeline: { commitPath: '/-/commit/aabbccdd', diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js index 887d296222f..d8e3436479c 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -2,8 +2,11 @@ import { GlAlert, GlButton, GlLoadingIcon, GlTabs } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import httpStatusCodes from '~/lib/utils/http_status'; +import CodeSnippetAlert from '~/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue'; +import { CODE_SNIPPET_SOURCES } from '~/pipeline_editor/components/code_snippet_alert/constants'; import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue'; import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue'; @@ -72,7 +75,7 @@ describe('Pipeline editor app component', () => { }); }; - const createComponentWithApollo = ({ props = {}, provide = {} } = {}) => { + const createComponentWithApollo = async ({ props = {}, provide = {} } = {}) => { const handlers = [[getCiConfigData, mockCiConfigData]]; const resolvers = { Query: { @@ -94,6 +97,8 @@ describe('Pipeline editor app component', () => { }; createComponent({ props, provide, options }); + + return waitForPromises(); }; const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); @@ -103,6 +108,7 @@ describe('Pipeline editor app component', () => { const findEmptyState = () => wrapper.findComponent(PipelineEditorEmptyState); const findEmptyStateButton = () => wrapper.findComponent(PipelineEditorEmptyState).findComponent(GlButton); + const findCodeSnippetAlert = () => wrapper.findComponent(CodeSnippetAlert); beforeEach(() => { mockBlobContentData = jest.fn(); @@ -116,11 +122,55 @@ describe('Pipeline editor app component', () => { wrapper.destroy(); }); - it('displays a loading icon if the blob query is loading', () => { - createComponent({ blobLoading: true }); + describe('loading state', () => { + it('displays a loading icon if the blob query is loading', () => { + createComponent({ blobLoading: true }); + + expect(findLoadingIcon().exists()).toBe(true); + expect(findTextEditor().exists()).toBe(false); + }); + }); + + describe('code snippet alert', () => { + const setCodeSnippetUrlParam = (value) => { + global.jsdom.reconfigure({ + url: `${TEST_HOST}/?code_snippet_copied_from=${value}`, + }); + }; + + it('does not show by default', () => { + createComponent(); + + expect(findCodeSnippetAlert().exists()).toBe(false); + }); + + it.each(CODE_SNIPPET_SOURCES)('shows if URL param is %s, and cleans up URL', (source) => { + jest.spyOn(window.history, 'replaceState'); + setCodeSnippetUrlParam(source); + createComponent(); + + expect(findCodeSnippetAlert().exists()).toBe(true); + expect(window.history.replaceState).toHaveBeenCalledWith({}, document.title, `${TEST_HOST}/`); + }); + + it('does not show if URL param is invalid', () => { + setCodeSnippetUrlParam('foo_bar'); + createComponent(); + + expect(findCodeSnippetAlert().exists()).toBe(false); + }); + + it('disappears on dismiss', async () => { + setCodeSnippetUrlParam('api_fuzzing'); + createComponent(); + const alert = findCodeSnippetAlert(); + + expect(alert.exists()).toBe(true); + + await alert.vm.$emit('dismiss'); - expect(findLoadingIcon().exists()).toBe(true); - expect(findTextEditor().exists()).toBe(false); + expect(alert.exists()).toBe(false); + }); }); describe('when queries are called', () => { @@ -131,9 +181,7 @@ describe('Pipeline editor app component', () => { describe('when file exists', () => { beforeEach(async () => { - createComponentWithApollo(); - - await waitForPromises(); + await createComponentWithApollo(); }); it('shows pipeline editor home component', () => { @@ -145,10 +193,6 @@ describe('Pipeline editor app component', () => { }); it('ci config query is called with correct variables', async () => { - createComponentWithApollo(); - - await waitForPromises(); - expect(mockCiConfigData).toHaveBeenCalledWith({ content: mockCiYml, projectPath: mockProjectFullPath, @@ -164,9 +208,7 @@ describe('Pipeline editor app component', () => { status: httpStatusCodes.BAD_REQUEST, }, }); - createComponentWithApollo(); - - await waitForPromises(); + await createComponentWithApollo(); expect(findEmptyState().exists()).toBe(true); expect(findAlert().exists()).toBe(false); @@ -181,9 +223,7 @@ describe('Pipeline editor app component', () => { status: httpStatusCodes.NOT_FOUND, }, }); - createComponentWithApollo(); - - await waitForPromises(); + await createComponentWithApollo(); expect(findEmptyState().exists()).toBe(true); expect(findAlert().exists()).toBe(false); @@ -194,8 +234,7 @@ describe('Pipeline editor app component', () => { describe('because of a fetching error', () => { it('shows a unkown error message', async () => { mockBlobContentData.mockRejectedValueOnce(new Error('My error!')); - createComponentWithApollo(); - await waitForPromises(); + await createComponentWithApollo(); expect(findEmptyState().exists()).toBe(false); expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[LOAD_FAILURE_UNKNOWN]); @@ -212,7 +251,7 @@ describe('Pipeline editor app component', () => { }, }); - createComponentWithApollo({ + await createComponentWithApollo({ provide: { glFeatures: { pipelineEditorEmptyStateAction: true, @@ -220,8 +259,6 @@ describe('Pipeline editor app component', () => { }, }); - await waitForPromises(); - expect(findEmptyState().exists()).toBe(true); expect(findTextEditor().exists()).toBe(false); @@ -254,9 +291,9 @@ describe('Pipeline editor app component', () => { describe('and the commit mutation fails', () => { const commitFailedReasons = ['Commit failed']; - beforeEach(() => { + beforeEach(async () => { window.scrollTo = jest.fn(); - createComponent(); + await createComponentWithApollo(); findEditorHome().vm.$emit('showError', { type: COMMIT_FAILURE, @@ -278,9 +315,9 @@ describe('Pipeline editor app component', () => { describe('when an unknown error occurs', () => { const unknownReasons = ['Commit failed']; - beforeEach(() => { + beforeEach(async () => { window.scrollTo = jest.fn(); - createComponent(); + await createComponentWithApollo(); findEditorHome().vm.$emit('showError', { type: COMMIT_FAILURE, diff --git a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js index 9864f3c13f9..a1e3d24acfa 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js @@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import CommitSection from '~/pipeline_editor/components/commit/commit_section.vue'; +import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue'; import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue'; import { MERGED_TAB, VISUALIZE_TAB } from '~/pipeline_editor/constants'; @@ -18,6 +19,7 @@ describe('Pipeline editor home wrapper', () => { ciConfigData: mockLintResponse, ciFileContent: mockCiYml, isCiConfigDataLoading: false, + isNewCiConfigFile: false, ...props, }, }); @@ -26,6 +28,7 @@ describe('Pipeline editor home wrapper', () => { const findPipelineEditorHeader = () => wrapper.findComponent(PipelineEditorHeader); const findPipelineEditorTabs = () => wrapper.findComponent(PipelineEditorTabs); const findCommitSection = () => wrapper.findComponent(CommitSection); + const findFileNav = () => wrapper.findComponent(PipelineEditorFileNav); afterEach(() => { wrapper.destroy(); @@ -37,6 +40,10 @@ describe('Pipeline editor home wrapper', () => { createComponent(); }); + it('shows the file nav', () => { + expect(findFileNav().exists()).toBe(true); + }); + it('shows the pipeline editor header', () => { expect(findPipelineEditorHeader().exists()).toBe(true); }); diff --git a/spec/frontend/pipelines/blank_state_spec.js b/spec/frontend/pipelines/blank_state_spec.js deleted file mode 100644 index 5dcf3d267ed..00000000000 --- a/spec/frontend/pipelines/blank_state_spec.js +++ /dev/null @@ -1,20 +0,0 @@ -import { getByText } from '@testing-library/dom'; -import { mount } from '@vue/test-utils'; -import BlankState from '~/pipelines/components/pipelines_list/blank_state.vue'; - -describe('Pipelines Blank State', () => { - const wrapper = mount(BlankState, { - propsData: { - svgPath: 'foo', - message: 'Blank State', - }, - }); - - it('should render svg', () => { - expect(wrapper.find('.svg-content img').attributes('src')).toEqual('foo'); - }); - - it('should render message', () => { - expect(getByText(wrapper.element, /Blank State/i)).toBeTruthy(); - }); -}); diff --git a/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js b/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js index 60026f69b84..93bc8faa51b 100644 --- a/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js +++ b/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js @@ -11,10 +11,15 @@ const dropdownPath = 'path.json'; describe('Pipelines stage component', () => { let wrapper; let mock; + let glTooltipDirectiveMock; const createComponent = (props = {}) => { + glTooltipDirectiveMock = jest.fn(); wrapper = mount(PipelineStage, { attachTo: document.body, + directives: { + GlTooltip: glTooltipDirectiveMock, + }, propsData: { stage: { status: { @@ -62,6 +67,10 @@ describe('Pipelines stage component', () => { createComponent(); }); + it('sets up the tooltip to not have a show delay animation', () => { + expect(glTooltipDirectiveMock.mock.calls[0][1].modifiers.ds0).toBe(true); + }); + it('should render a dropdown with the status icon', () => { expect(findDropdown().exists()).toBe(true); expect(findDropdownToggle().exists()).toBe(true); diff --git a/spec/frontend/pipelines/graph/action_component_spec.js b/spec/frontend/pipelines/graph/action_component_spec.js index 6a7018fa1e5..177b026491c 100644 --- a/spec/frontend/pipelines/graph/action_component_spec.js +++ b/spec/frontend/pipelines/graph/action_component_spec.js @@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; -import ActionComponent from '~/pipelines/components/graph/action_component.vue'; +import ActionComponent from '~/pipelines/components/jobs_shared/action_component.vue'; describe('pipeline graph action component', () => { let wrapper; diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js index 6c3f848333c..e8fb036368a 100644 --- a/spec/frontend/pipelines/graph/graph_component_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_spec.js @@ -1,10 +1,11 @@ import { mount, shallowMount } from '@vue/test-utils'; -import { GRAPHQL } from '~/pipelines/components/graph/constants'; +import { GRAPHQL, 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'; import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; +import { listByLayers } from '~/pipelines/components/parsing_utils'; import { generateResponse, mockPipelineResponse, @@ -17,9 +18,11 @@ describe('graph component', () => { const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn); const findLinksLayer = () => wrapper.find(LinksLayer); const findStageColumns = () => wrapper.findAll(StageColumnComponent); + const findStageNameInJob = () => wrapper.find('[data-testid="stage-name-in-job"]'); const defaultProps = { pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'), + viewType: STAGE_VIEW, configPaths: { metricsPath: '', graphqlResourceEtag: 'this/is/a/path', @@ -81,6 +84,10 @@ describe('graph component', () => { expect(findLinksLayer().exists()).toBe(true); }); + it('does not display stage name on the job in default (stage) mode', () => { + expect(findStageNameInJob().exists()).toBe(false); + }); + describe('when column requests a refresh', () => { beforeEach(() => { findStageColumns().at(0).vm.$emit('refreshPipelineGraph'); @@ -92,7 +99,7 @@ describe('graph component', () => { }); describe('when links are present', () => { - beforeEach(async () => { + beforeEach(() => { createComponent({ mountFn: mount, stubOverride: { 'job-item': false }, @@ -131,4 +138,24 @@ describe('graph component', () => { expect(findLinkedColumns()).toHaveLength(2); }); }); + + describe('in layers mode', () => { + beforeEach(() => { + createComponent({ + mountFn: mount, + stubOverride: { + 'job-item': false, + 'job-group-dropdown': false, + }, + props: { + viewType: LAYER_VIEW, + pipelineLayers: listByLayers(defaultProps.pipeline), + }, + }); + }); + + it('displays the stage name on the job', () => { + expect(findStageNameInJob().exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js index 44d8e467f51..8c469966be4 100644 --- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js @@ -1,11 +1,21 @@ import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { mount, shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; +import { + IID_FAILURE, + LAYER_VIEW, + STAGE_VIEW, + VIEW_TYPE_KEY, +} from '~/pipelines/components/graph/constants'; import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; import PipelineGraphWrapper from '~/pipelines/components/graph/graph_component_wrapper.vue'; +import GraphViewSelector from '~/pipelines/components/graph/graph_view_selector.vue'; +import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; +import * as parsingUtils from '~/pipelines/components/parsing_utils'; import { mockPipelineResponse } from './mock_data'; const defaultProvide = { @@ -17,20 +27,28 @@ const defaultProvide = { describe('Pipeline graph wrapper', () => { Vue.use(VueApollo); + useLocalStorageSpy(); let wrapper; const getAlert = () => wrapper.find(GlAlert); const getLoadingIcon = () => wrapper.find(GlLoadingIcon); const getGraph = () => wrapper.find(PipelineGraph); + const getStageColumnTitle = () => wrapper.find('[data-testid="stage-column-title"]'); + const getAllStageColumnGroupsInColumn = () => + wrapper.find(StageColumnComponent).findAll('[data-testid="stage-column-group"]'); + const getViewSelector = () => wrapper.find(GraphViewSelector); const createComponent = ({ apolloProvider, data = {}, - provide = defaultProvide, + provide = {}, mountFn = shallowMount, } = {}) => { wrapper = mountFn(PipelineGraphWrapper, { - provide, + provide: { + ...defaultProvide, + ...provide, + }, apolloProvider, data() { return { @@ -40,13 +58,15 @@ describe('Pipeline graph wrapper', () => { }); }; - const createComponentWithApollo = ( + const createComponentWithApollo = ({ getPipelineDetailsHandler = jest.fn().mockResolvedValue(mockPipelineResponse), - ) => { + mountFn = shallowMount, + provide = {}, + } = {}) => { const requestHandlers = [[getPipelineDetails, getPipelineDetailsHandler]]; const apolloProvider = createMockApollo(requestHandlers); - createComponent({ apolloProvider }); + createComponent({ apolloProvider, provide, mountFn }); }; afterEach(() => { @@ -100,7 +120,9 @@ describe('Pipeline graph wrapper', () => { describe('when there is an error', () => { beforeEach(async () => { - createComponentWithApollo(jest.fn().mockRejectedValue(new Error('GraphQL error'))); + createComponentWithApollo({ + getPipelineDetailsHandler: jest.fn().mockRejectedValue(new Error('GraphQL error')), + }); jest.runOnlyPendingTimers(); await wrapper.vm.$nextTick(); }); @@ -118,6 +140,31 @@ describe('Pipeline graph wrapper', () => { }); }); + describe('when there is no pipeline iid available', () => { + beforeEach(async () => { + createComponentWithApollo({ + provide: { + pipelineIid: '', + }, + }); + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + }); + + it('does not display the loading icon', () => { + expect(getLoadingIcon().exists()).toBe(false); + }); + + it('displays the no iid alert', () => { + expect(getAlert().exists()).toBe(true); + expect(getAlert().text()).toBe(wrapper.vm.$options.errorTexts[IID_FAILURE]); + }); + + it('does not display the graph', () => { + expect(getGraph().exists()).toBe(false); + }); + }); + describe('when refresh action is emitted', () => { beforeEach(async () => { createComponentWithApollo(); @@ -154,7 +201,7 @@ describe('Pipeline graph wrapper', () => { .mockResolvedValueOnce(mockPipelineResponse) .mockResolvedValueOnce(errorData); - createComponentWithApollo(failSucceedFail); + createComponentWithApollo({ getPipelineDetailsHandler: failSucceedFail }); await wrapper.vm.$nextTick(); }); @@ -174,4 +221,113 @@ describe('Pipeline graph wrapper', () => { expect(getGraph().exists()).toBe(true); }); }); + + describe('view dropdown', () => { + describe('when pipelineGraphLayersView feature flag is off', () => { + beforeEach(async () => { + createComponentWithApollo(); + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + }); + + it('does not appear', () => { + expect(getViewSelector().exists()).toBe(false); + }); + }); + + describe('when pipelineGraphLayersView feature flag is on', () => { + let layersFn; + beforeEach(async () => { + layersFn = jest.spyOn(parsingUtils, 'listByLayers'); + createComponentWithApollo({ + provide: { + glFeatures: { + pipelineGraphLayersView: true, + }, + }, + mountFn: mount, + }); + + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + }); + + it('appears when pipeline uses needs', () => { + expect(getViewSelector().exists()).toBe(true); + }); + + it('switches between views', async () => { + const groupsInFirstColumn = + mockPipelineResponse.data.project.pipeline.stages.nodes[0].groups.nodes.length; + expect(getAllStageColumnGroupsInColumn()).toHaveLength(groupsInFirstColumn); + expect(getStageColumnTitle().text()).toBe('Build'); + await getViewSelector().vm.$emit('updateViewType', LAYER_VIEW); + expect(getAllStageColumnGroupsInColumn()).toHaveLength(groupsInFirstColumn + 1); + expect(getStageColumnTitle().text()).toBe(''); + }); + + it('saves the view type to local storage', async () => { + await getViewSelector().vm.$emit('updateViewType', LAYER_VIEW); + expect(localStorage.setItem.mock.calls).toEqual([[VIEW_TYPE_KEY, LAYER_VIEW]]); + }); + + it('calls listByLayers only once no matter how many times view is switched', async () => { + expect(layersFn).not.toHaveBeenCalled(); + await getViewSelector().vm.$emit('updateViewType', LAYER_VIEW); + expect(layersFn).toHaveBeenCalledTimes(1); + await getViewSelector().vm.$emit('updateViewType', STAGE_VIEW); + await getViewSelector().vm.$emit('updateViewType', LAYER_VIEW); + await getViewSelector().vm.$emit('updateViewType', STAGE_VIEW); + expect(layersFn).toHaveBeenCalledTimes(1); + }); + }); + + describe('when feature flag is on and local storage is set', () => { + beforeEach(async () => { + localStorage.setItem(VIEW_TYPE_KEY, LAYER_VIEW); + + createComponentWithApollo({ + provide: { + glFeatures: { + pipelineGraphLayersView: true, + }, + }, + mountFn: mount, + }); + + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + }); + + it('reads the view type from localStorage when available', () => { + expect(wrapper.find('[data-testid="pipeline-view-selector"] code').text()).toContain( + 'needs:', + ); + }); + }); + + describe('when feature flag is on but pipeline does not use needs', () => { + beforeEach(async () => { + const nonNeedsResponse = { ...mockPipelineResponse }; + nonNeedsResponse.data.project.pipeline.usesNeeds = false; + + createComponentWithApollo({ + provide: { + glFeatures: { + pipelineGraphLayersView: true, + }, + }, + mountFn: mount, + getPipelineDetailsHandler: jest.fn().mockResolvedValue(nonNeedsResponse), + }); + + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + }); + + it('does not appear when pipeline does not use needs', () => { + expect(getViewSelector().exists()).toBe(false); + }); + }); + }); }); diff --git a/spec/frontend/pipelines/graph/job_group_dropdown_spec.js b/spec/frontend/pipelines/graph/job_group_dropdown_spec.js index b323e1d8a06..5d8e70bac31 100644 --- a/spec/frontend/pipelines/graph/job_group_dropdown_spec.js +++ b/spec/frontend/pipelines/graph/job_group_dropdown_spec.js @@ -1,4 +1,4 @@ -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, mount } from '@vue/test-utils'; import JobGroupDropdown from '~/pipelines/components/graph/job_group_dropdown.vue'; describe('job group dropdown component', () => { @@ -65,12 +65,16 @@ describe('job group dropdown component', () => { let wrapper; const findButton = () => wrapper.find('button'); + const createComponent = ({ mountFn = shallowMount }) => { + wrapper = mountFn(JobGroupDropdown, { propsData: { group } }); + }; + afterEach(() => { wrapper.destroy(); }); beforeEach(() => { - wrapper = shallowMount(JobGroupDropdown, { propsData: { group } }); + createComponent({ mountFn: mount }); }); it('renders button with group name and size', () => { diff --git a/spec/frontend/pipelines/graph/job_item_spec.js b/spec/frontend/pipelines/graph/job_item_spec.js index cb2837cbb39..4c7ea5edda9 100644 --- a/spec/frontend/pipelines/graph/job_item_spec.js +++ b/spec/frontend/pipelines/graph/job_item_spec.js @@ -122,7 +122,7 @@ describe('pipeline graph job item', () => { }, }); - expect(wrapper.find('.js-job-component-tooltip').attributes('title')).toBe('test'); + expect(findJobWithoutLink().attributes('title')).toBe('test'); }); it('should not render status label when it is provided', () => { @@ -138,7 +138,7 @@ describe('pipeline graph job item', () => { }, }); - expect(wrapper.find('.js-job-component-tooltip').attributes('title')).toBe('test - success'); + expect(findJobWithoutLink().attributes('title')).toBe('test - success'); }); }); diff --git a/spec/frontend/pipelines/graph/job_name_component_spec.js b/spec/frontend/pipelines/graph/job_name_component_spec.js index 658b5be87d4..d3008c046e8 100644 --- a/spec/frontend/pipelines/graph/job_name_component_spec.js +++ b/spec/frontend/pipelines/graph/job_name_component_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import jobNameComponent from '~/pipelines/components/graph/job_name_component.vue'; +import jobNameComponent from '~/pipelines/components/jobs_shared/job_name_component.vue'; import ciIcon from '~/vue_shared/components/ci_icon.vue'; describe('job name component', () => { diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js index 4c72dad735e..8aecfc1b649 100644 --- a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js @@ -2,10 +2,17 @@ import { mount, shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; -import { DOWNSTREAM, GRAPHQL, UPSTREAM } from '~/pipelines/components/graph/constants'; +import { + DOWNSTREAM, + GRAPHQL, + UPSTREAM, + LAYER_VIEW, + STAGE_VIEW, +} from '~/pipelines/components/graph/constants'; import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; import LinkedPipeline from '~/pipelines/components/graph/linked_pipeline.vue'; import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; +import * as parsingUtils from '~/pipelines/components/parsing_utils'; import { LOAD_FAILURE } from '~/pipelines/constants'; import { mockPipelineResponse, @@ -20,6 +27,7 @@ describe('Linked Pipelines Column', () => { columnTitle: 'Downstream', linkedPipelines: processedPipeline.downstream, type: DOWNSTREAM, + viewType: STAGE_VIEW, configPaths: { metricsPath: '', graphqlResourceEtag: 'this/is/a/path', @@ -67,7 +75,7 @@ describe('Linked Pipelines Column', () => { describe('it renders correctly', () => { beforeEach(() => { - createComponent(); + createComponentWithApollo(); }); it('renders the pipeline title', () => { @@ -91,6 +99,27 @@ describe('Linked Pipelines Column', () => { await wrapper.vm.$nextTick(); }; + describe('layer type rendering', () => { + let layersFn; + + beforeEach(() => { + layersFn = jest.spyOn(parsingUtils, 'listByLayers'); + createComponentWithApollo({ mountFn: mount }); + }); + + it('calls listByLayers only once no matter how many times view is switched', async () => { + expect(layersFn).not.toHaveBeenCalled(); + await clickExpandButtonAndAwaitTimers(); + await wrapper.setProps({ viewType: LAYER_VIEW }); + await wrapper.vm.$nextTick(); + expect(layersFn).toHaveBeenCalledTimes(1); + await wrapper.setProps({ viewType: STAGE_VIEW }); + await wrapper.setProps({ viewType: LAYER_VIEW }); + await wrapper.setProps({ viewType: STAGE_VIEW }); + expect(layersFn).toHaveBeenCalledTimes(1); + }); + }); + describe('downstream', () => { describe('when successful', () => { beforeEach(() => { diff --git a/spec/frontend/pipelines/graph/mock_data.js b/spec/frontend/pipelines/graph/mock_data.js index 7650cbd2d5c..cf420f68f37 100644 --- a/spec/frontend/pipelines/graph/mock_data.js +++ b/spec/frontend/pipelines/graph/mock_data.js @@ -8,6 +8,7 @@ export const mockPipelineResponse = { __typename: 'Pipeline', id: 163, iid: '22', + usesNeeds: true, downstream: null, upstream: null, stages: { @@ -434,21 +435,7 @@ export const mockPipelineResponse = { }, needs: { __typename: 'CiBuildNeedConnection', - nodes: [ - { - __typename: 'CiBuildNeed', - name: 'build_c', - }, - { - __typename: 'CiBuildNeed', - name: 'build_b', - }, - { - __typename: 'CiBuildNeed', - name: - 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', - }, - ], + nodes: [], }, }, ], @@ -583,6 +570,7 @@ export const wrappedPipelineReturn = { __typename: 'Pipeline', id: 'gid://gitlab/Ci::Pipeline/175', iid: '38', + usesNeeds: true, downstream: { __typename: 'PipelineConnection', nodes: [], diff --git a/spec/frontend/pipelines/graph/stage_column_component_spec.js b/spec/frontend/pipelines/graph/stage_column_component_spec.js index 16dc70a63a5..f9f6c96a1a6 100644 --- a/spec/frontend/pipelines/graph/stage_column_component_spec.js +++ b/spec/frontend/pipelines/graph/stage_column_component_spec.js @@ -1,7 +1,7 @@ import { mount, shallowMount } from '@vue/test-utils'; -import ActionComponent from '~/pipelines/components/graph/action_component.vue'; import JobItem from '~/pipelines/components/graph/job_item.vue'; import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; +import ActionComponent from '~/pipelines/components/jobs_shared/action_component.vue'; const mockJob = { id: 4250, @@ -24,11 +24,11 @@ const mockJob = { const mockGroups = Array(4) .fill(0) .map((item, idx) => { - return { ...mockJob, id: idx, name: `fish-${idx}` }; + return { ...mockJob, jobs: [mockJob], id: idx, name: `fish-${idx}` }; }); const defaultProps = { - title: 'Fish', + name: 'Fish', groups: mockGroups, pipelineId: 159, }; @@ -62,7 +62,7 @@ describe('stage column component', () => { }); it('should render provided title', () => { - expect(findStageColumnTitle().text()).toBe(defaultProps.title); + expect(findStageColumnTitle().text()).toBe(defaultProps.name); }); it('should render the provided groups', () => { @@ -104,16 +104,22 @@ describe('stage column component', () => { props: { groups: [ { - id: 4259, + ...mockJob, name: '<img src=x onerror=alert(document.domain)>', - status: { - icon: 'status_success', - label: 'success', - tooltip: '<img src=x onerror=alert(document.domain)>', - }, + jobs: [ + { + id: 4259, + name: '<img src=x onerror=alert(document.domain)>', + status: { + icon: 'status_success', + label: 'success', + tooltip: '<img src=x onerror=alert(document.domain)>', + }, + }, + ], }, ], - title: 'test <img src=x onerror=alert(document.domain)>', + name: 'test <img src=x onerror=alert(document.domain)>', }, }); }); @@ -159,6 +165,7 @@ describe('stage column component', () => { label: 'success', tooltip: '<img src=x onerror=alert(document.domain)>', }, + jobs: [mockJob], }, ], title: 'test', @@ -191,6 +198,7 @@ describe('stage column component', () => { label: 'success', tooltip: '<img src=x onerror=alert(document.domain)>', }, + jobs: [mockJob], }, ], title: 'test', diff --git a/spec/frontend/pipelines/graph_shared/links_inner_spec.js b/spec/frontend/pipelines/graph_shared/links_inner_spec.js index 6fef1c9b62e..e81f046c1eb 100644 --- a/spec/frontend/pipelines/graph_shared/links_inner_spec.js +++ b/spec/frontend/pipelines/graph_shared/links_inner_spec.js @@ -8,9 +8,9 @@ import { PIPELINES_DETAIL_LINKS_JOB_RATIO, } from '~/performance/constants'; import * as perfUtils from '~/performance/utils'; -import * as sentryUtils from '~/pipelines/components/graph/utils'; import * as Api from '~/pipelines/components/graph_shared/api'; import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue'; +import * as sentryUtils from '~/pipelines/utils'; import { createJobsHash } from '~/pipelines/utils'; import { jobRect, diff --git a/spec/frontend/pipelines/graph_shared/links_layer_spec.js b/spec/frontend/pipelines/graph_shared/links_layer_spec.js index 43d8fe28893..5e5365eef30 100644 --- a/spec/frontend/pipelines/graph_shared/links_layer_spec.js +++ b/spec/frontend/pipelines/graph_shared/links_layer_spec.js @@ -1,4 +1,5 @@ -import { GlAlert, GlButton } from '@gitlab/ui'; +import { GlAlert } from '@gitlab/ui'; +import { fireEvent, within } from '@testing-library/dom'; import { mount, shallowMount } from '@vue/test-utils'; import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue'; import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; @@ -7,8 +8,10 @@ import { generateResponse, mockPipelineResponse } from '../graph/mock_data'; describe('links layer component', () => { let wrapper; + const withinComponent = () => within(wrapper.element); const findAlert = () => wrapper.find(GlAlert); - const findShowAnyways = () => findAlert().find(GlButton); + const findShowAnyways = () => + withinComponent().getByText(wrapper.vm.$options.i18n.showLinksAnyways); const findLinksInner = () => wrapper.find(LinksInner); const pipeline = generateResponse(mockPipelineResponse, 'root/fungi-xoxo'); @@ -103,13 +106,13 @@ describe('links layer component', () => { }); it('renders the disable button', () => { - expect(findShowAnyways().exists()).toBe(true); - expect(findShowAnyways().text()).toBe(wrapper.vm.$options.i18n.showLinksAnyways); + expect(findShowAnyways()).not.toBe(null); }); it('shows links when override is clicked', async () => { expect(findLinksInner().exists()).toBe(false); - await findShowAnyways().trigger('click'); + fireEvent(findShowAnyways(), new MouseEvent('click', { bubbles: true })); + await wrapper.vm.$nextTick(); expect(findLinksInner().exists()).toBe(true); }); }); diff --git a/spec/frontend/pipelines/nav_controls_spec.js b/spec/frontend/pipelines/nav_controls_spec.js index 305dc557b39..2c4740df174 100644 --- a/spec/frontend/pipelines/nav_controls_spec.js +++ b/spec/frontend/pipelines/nav_controls_spec.js @@ -1,17 +1,22 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import navControlsComp from '~/pipelines/components/pipelines_list/nav_controls.vue'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import NavControls from '~/pipelines/components/pipelines_list/nav_controls.vue'; describe('Pipelines Nav Controls', () => { - let NavControlsComponent; - let component; + let wrapper; - beforeEach(() => { - NavControlsComponent = Vue.extend(navControlsComp); - }); + const createComponent = (props) => { + wrapper = shallowMount(NavControls, { + propsData: { + ...props, + }, + }); + }; + + const findRunPipeline = () => wrapper.find('.js-run-pipeline'); afterEach(() => { - component.$destroy(); + wrapper.destroy(); }); it('should render link to create a new pipeline', () => { @@ -21,12 +26,11 @@ describe('Pipelines Nav Controls', () => { resetCachePath: 'foo', }; - component = mountComponent(NavControlsComponent, mockData); + createComponent(mockData); - expect(component.$el.querySelector('.js-run-pipeline').textContent).toContain('Run Pipeline'); - expect(component.$el.querySelector('.js-run-pipeline').getAttribute('href')).toEqual( - mockData.newPipelinePath, - ); + const runPipeline = findRunPipeline(); + expect(runPipeline.text()).toContain('Run pipeline'); + expect(runPipeline.attributes('href')).toBe(mockData.newPipelinePath); }); it('should not render link to create pipeline if no path is provided', () => { @@ -36,9 +40,9 @@ describe('Pipelines Nav Controls', () => { resetCachePath: 'foo', }; - component = mountComponent(NavControlsComponent, mockData); + createComponent(mockData); - expect(component.$el.querySelector('.js-run-pipeline')).toEqual(null); + expect(findRunPipeline().exists()).toBe(false); }); it('should render link for CI lint', () => { @@ -49,12 +53,10 @@ describe('Pipelines Nav Controls', () => { resetCachePath: 'foo', }; - component = mountComponent(NavControlsComponent, mockData); + createComponent(mockData); - expect(component.$el.querySelector('.js-ci-lint').textContent.trim()).toContain('CI Lint'); - expect(component.$el.querySelector('.js-ci-lint').getAttribute('href')).toEqual( - mockData.ciLintPath, - ); + expect(wrapper.find('.js-ci-lint').text().trim()).toContain('CI lint'); + expect(wrapper.find('.js-ci-lint').attributes('href')).toBe(mockData.ciLintPath); }); describe('Reset Runners Cache', () => { @@ -64,22 +66,20 @@ describe('Pipelines Nav Controls', () => { ciLintPath: 'foo', resetCachePath: 'foo', }; - - component = mountComponent(NavControlsComponent, mockData); + createComponent(mockData); }); it('should render button for resetting runner caches', () => { - expect(component.$el.querySelector('.js-clear-cache').textContent.trim()).toContain( - 'Clear Runner Caches', - ); + expect(wrapper.find('.js-clear-cache').text().trim()).toContain('Clear runner caches'); }); - it('should emit postAction event when reset runner cache button is clicked', () => { - jest.spyOn(component, '$emit').mockImplementation(() => {}); + it('should emit postAction event when reset runner cache button is clicked', async () => { + jest.spyOn(wrapper.vm, '$emit').mockImplementation(() => {}); - component.$el.querySelector('.js-clear-cache').click(); + wrapper.find('.js-clear-cache').vm.$emit('click'); + await nextTick(); - expect(component.$emit).toHaveBeenCalledWith('resetRunnersCache', 'foo'); + expect(wrapper.vm.$emit).toHaveBeenCalledWith('resetRunnersCache', 'foo'); }); }); }); diff --git a/spec/frontend/pipelines/notification/pipeline_notification_spec.js b/spec/frontend/pipelines/notification/pipeline_notification_spec.js new file mode 100644 index 00000000000..79aa337ba9d --- /dev/null +++ b/spec/frontend/pipelines/notification/pipeline_notification_spec.js @@ -0,0 +1,79 @@ +import { GlBanner } from '@gitlab/ui'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import PipelineNotification from '~/pipelines/components/notification/pipeline_notification.vue'; +import getUserCallouts from '~/pipelines/graphql/queries/get_user_callouts.query.graphql'; + +describe('Pipeline notification', () => { + const localVue = createLocalVue(); + + let wrapper; + const dagDocPath = 'my/dag/path'; + + const createWrapper = (apolloProvider) => { + return shallowMount(PipelineNotification, { + localVue, + provide: { + dagDocPath, + }, + apolloProvider, + }); + }; + + const createWrapperWithApollo = async ({ callouts = [], isLoading = false } = {}) => { + localVue.use(VueApollo); + + const mappedCallouts = callouts.map((callout) => { + return { featureName: callout, __typename: 'UserCallout' }; + }); + + const mockCalloutsResponse = { + data: { + currentUser: { + id: 45, + __typename: 'User', + callouts: { + id: 5, + __typename: 'UserCalloutConnection', + nodes: mappedCallouts, + }, + }, + }, + }; + const getUserCalloutsHandler = jest.fn().mockResolvedValue(mockCalloutsResponse); + const requestHandlers = [[getUserCallouts, getUserCalloutsHandler]]; + + const apolloWrapper = createWrapper(createMockApollo(requestHandlers)); + if (!isLoading) { + await nextTick(); + } + + return apolloWrapper; + }; + + const findBanner = () => wrapper.findComponent(GlBanner); + + afterEach(() => { + wrapper.destroy(); + }); + + it('shows the banner if the user has never seen it', async () => { + wrapper = await createWrapperWithApollo({ callouts: ['random'] }); + + expect(findBanner().exists()).toBe(true); + }); + + it('does not show the banner while the user callout query is loading', async () => { + wrapper = await createWrapperWithApollo({ callouts: ['random'], isLoading: true }); + + expect(findBanner().exists()).toBe(false); + }); + + it('does not show the banner if the user has previously dismissed it', async () => { + wrapper = await createWrapperWithApollo({ callouts: ['pipeline_needs_banner'.toUpperCase()] }); + + expect(findBanner().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/pipelines/pipeline_graph/mock_data.js b/spec/frontend/pipelines/pipeline_graph/mock_data.js index 339aac9f349..a79917bfd48 100644 --- a/spec/frontend/pipelines/pipeline_graph/mock_data.js +++ b/spec/frontend/pipelines/pipeline_graph/mock_data.js @@ -98,6 +98,42 @@ export const pipelineData = { ], }; +export const invalidNeedsData = { + stages: [ + { + name: 'build', + groups: [ + { + name: 'build_1', + jobs: [{ script: 'echo hello', stage: 'build' }], + }, + ], + }, + { + name: 'test', + groups: [ + { + name: 'test_1', + jobs: [{ script: 'yarn test', stage: 'test' }], + }, + { + name: 'test_2', + jobs: [{ script: 'yarn karma', stage: 'test' }], + }, + ], + }, + { + name: 'deploy', + groups: [ + { + name: 'deploy_1', + jobs: [{ script: 'yarn magick', stage: 'deploy', needs: ['invalid_job'] }], + }, + ], + }, + ], +}; + export const parallelNeedData = { stages: [ { diff --git a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js index 718667fcc73..258f2bda829 100644 --- a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js +++ b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js @@ -1,11 +1,13 @@ import { GlAlert } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { CI_CONFIG_STATUS_INVALID, CI_CONFIG_STATUS_VALID } from '~/pipeline_editor/constants'; +import { CI_CONFIG_STATUS_VALID } from '~/pipeline_editor/constants'; +import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue'; +import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import StagePill from '~/pipelines/components/pipeline_graph/stage_pill.vue'; -import { DRAW_FAILURE, EMPTY_PIPELINE_DATA, INVALID_CI_CONFIG } from '~/pipelines/constants'; -import { pipelineData, singleStageData } from './mock_data'; +import { DRAW_FAILURE } from '~/pipelines/constants'; +import { invalidNeedsData, pipelineData, singleStageData } from './mock_data'; describe('pipeline graph component', () => { const defaultProps = { pipelineData }; @@ -16,50 +18,37 @@ describe('pipeline graph component', () => { propsData: { ...props, }, + stubs: { LinksLayer, LinksInner }, + data() { + return { + measurements: { + width: 1000, + height: 1000, + }, + }; + }, }); }; - const findPipelineGraph = () => wrapper.find('[data-testid="graph-container"]'); - const findAlert = () => wrapper.find(GlAlert); - const findAllStagePills = () => wrapper.findAll(StagePill); + const findAlert = () => wrapper.findComponent(GlAlert); + const findAllJobPills = () => wrapper.findAll(JobPill); const findAllStageBackgroundElements = () => wrapper.findAll('[data-testid="stage-background"]'); + const findAllStagePills = () => wrapper.findAllComponents(StagePill); + const findLinksLayer = () => wrapper.findComponent(LinksLayer); + const findPipelineGraph = () => wrapper.find('[data-testid="graph-container"]'); const findStageBackgroundElementAt = (index) => findAllStageBackgroundElements().at(index); - const findAllJobPills = () => wrapper.findAll(JobPill); afterEach(() => { wrapper.destroy(); - wrapper = null; - }); - - describe('with no data', () => { - beforeEach(() => { - wrapper = createComponent({ pipelineData: {} }); - }); - - it('renders an empty section', () => { - expect(wrapper.text()).toBe(wrapper.vm.$options.errorTexts[EMPTY_PIPELINE_DATA]); - expect(findPipelineGraph().exists()).toBe(false); - expect(findAllStagePills()).toHaveLength(0); - expect(findAllJobPills()).toHaveLength(0); - }); - }); - - describe('with `INVALID` status', () => { - beforeEach(() => { - wrapper = createComponent({ pipelineData: { status: CI_CONFIG_STATUS_INVALID } }); - }); - - it('renders an error message and does not render the graph', () => { - expect(findAlert().exists()).toBe(true); - expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[INVALID_CI_CONFIG]); - expect(findPipelineGraph().exists()).toBe(false); - }); }); describe('with `VALID` status', () => { beforeEach(() => { wrapper = createComponent({ - pipelineData: { status: CI_CONFIG_STATUS_VALID, stages: [{ name: 'hello', groups: [] }] }, + pipelineData: { + status: CI_CONFIG_STATUS_VALID, + stages: [{ name: 'hello', groups: [] }], + }, }); }); @@ -71,10 +60,11 @@ describe('pipeline graph component', () => { describe('with error while rendering the links with needs', () => { beforeEach(() => { - wrapper = createComponent(); + wrapper = createComponent({ pipelineData: invalidNeedsData }); }); it('renders the error that link could not be drawn', () => { + expect(findLinksLayer().exists()).toBe(true); expect(findAlert().exists()).toBe(true); expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[DRAW_FAILURE]); }); diff --git a/spec/frontend/pipelines/pipelines_ci_templates_spec.js b/spec/frontend/pipelines/pipelines_ci_templates_spec.js new file mode 100644 index 00000000000..d4cf6027ff7 --- /dev/null +++ b/spec/frontend/pipelines/pipelines_ci_templates_spec.js @@ -0,0 +1,111 @@ +import { shallowMount } from '@vue/test-utils'; +import ExperimentTracking from '~/experimentation/experiment_tracking'; +import PipelinesCiTemplate from '~/pipelines/components/pipelines_list/pipelines_ci_templates.vue'; + +const addCiYmlPath = "/-/new/master?commit_message='Add%20.gitlab-ci.yml'"; +const suggestedCiTemplates = [ + { name: 'Android', logo: '/assets/illustrations/logos/android.svg' }, + { name: 'Bash', logo: '/assets/illustrations/logos/bash.svg' }, + { name: 'C++', logo: '/assets/illustrations/logos/c_plus_plus.svg' }, +]; + +jest.mock('~/experimentation/experiment_tracking'); + +describe('Pipelines CI Templates', () => { + let wrapper; + + const GlEmoji = { template: '<img/>' }; + + const createWrapper = () => { + return shallowMount(PipelinesCiTemplate, { + provide: { + addCiYmlPath, + suggestedCiTemplates, + }, + stubs: { + GlEmoji, + }, + }); + }; + + const findTestTemplateLinks = () => wrapper.findAll('[data-testid="test-template-link"]'); + const findTemplateDescriptions = () => wrapper.findAll('[data-testid="template-description"]'); + const findTemplateLinks = () => wrapper.findAll('[data-testid="template-link"]'); + const findTemplateNames = () => wrapper.findAll('[data-testid="template-name"]'); + const findTemplateLogos = () => wrapper.findAll('[data-testid="template-logo"]'); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('renders test template', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('links to the hello world template', () => { + expect(findTestTemplateLinks().at(0).attributes('href')).toBe( + addCiYmlPath.concat('&template=Hello-World'), + ); + }); + }); + + describe('renders template list', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('renders all suggested templates', () => { + const content = wrapper.text(); + + expect(content).toContain('Android', 'Bash', 'C++'); + }); + + it('has the correct template name', () => { + expect(findTemplateNames().at(0).text()).toBe('Android'); + }); + + it('links to the correct template', () => { + expect(findTemplateLinks().at(0).attributes('href')).toBe( + addCiYmlPath.concat('&template=Android'), + ); + }); + + it('has the description of the template', () => { + expect(findTemplateDescriptions().at(0).text()).toBe( + 'CI/CD template to test and deploy your Android project.', + ); + }); + + it('has the right logo of the template', () => { + expect(findTemplateLogos().at(0).attributes('src')).toBe( + '/assets/illustrations/logos/android.svg', + ); + }); + }); + + describe('tracking', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('sends an event when template is clicked', () => { + findTemplateLinks().at(0).vm.$emit('click'); + + expect(ExperimentTracking).toHaveBeenCalledWith('pipeline_empty_state_templates', { + label: 'Android', + }); + expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('template_clicked'); + }); + + it('sends an event when Hello-World template is clicked', () => { + findTestTemplateLinks().at(0).vm.$emit('click'); + + expect(ExperimentTracking).toHaveBeenCalledWith('pipeline_empty_state_templates', { + label: 'Hello-World', + }); + expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('template_clicked'); + }); + }); +}); diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index b04880b43ae..84a25f42201 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -1,4 +1,4 @@ -import { GlButton, GlFilteredSearch, GlLoadingIcon, GlPagination } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlFilteredSearch, GlLoadingIcon, GlPagination } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { chunk } from 'lodash'; @@ -8,8 +8,6 @@ import waitForPromises from 'helpers/wait_for_promises'; import Api from '~/api'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import BlankState from '~/pipelines/components/pipelines_list/blank_state.vue'; -import EmptyState from '~/pipelines/components/pipelines_list/empty_state.vue'; import NavigationControls from '~/pipelines/components/pipelines_list/nav_controls.vue'; import PipelinesComponent from '~/pipelines/components/pipelines_list/pipelines.vue'; import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue'; @@ -58,11 +56,10 @@ describe('Pipelines', () => { }; const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findNavigationTabs = () => wrapper.findComponent(NavigationTabs); const findNavigationControls = () => wrapper.findComponent(NavigationControls); const findPipelinesTable = () => wrapper.findComponent(PipelinesTableComponent); - const findEmptyState = () => wrapper.findComponent(EmptyState); - const findBlankState = () => wrapper.findComponent(BlankState); const findTablePagination = () => wrapper.findComponent(TablePagination); const findTab = (tab) => wrapper.findByTestId(`pipelines-tab-${tab}`); @@ -194,16 +191,16 @@ describe('Pipelines', () => { expect(findNavigationControls().exists()).toBe(true); }); - it('renders Run Pipeline link', () => { + it('renders Run pipeline link', () => { expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); }); - it('renders CI Lint link', () => { + it('renders CI lint link', () => { expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); }); - it('renders Clear Runner Cache button', () => { - expect(findCleanCacheButton().text()).toBe('Clear Runner Caches'); + it('renders Clear runner cache button', () => { + expect(findCleanCacheButton().text()).toBe('Clear runner caches'); }); it('renders pipelines in a table', () => { @@ -268,7 +265,7 @@ describe('Pipelines', () => { }); it('should filter pipelines', async () => { - expect(findBlankState().text()).toBe('There are currently no pipelines.'); + expect(findEmptyState().text()).toBe('There are currently no pipelines.'); }); it('should update browser bar', () => { @@ -502,20 +499,24 @@ describe('Pipelines', () => { expect(findTab('all').text()).toMatchInterpolatedText('All 0'); }); - it('renders Run Pipeline link', () => { + it('renders Run pipeline link', () => { expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); }); - it('renders CI Lint link', () => { + it('renders CI lint link', () => { expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); }); - it('renders Clear Runner Cache button', () => { - expect(findCleanCacheButton().text()).toBe('Clear Runner Caches'); + it('renders Clear runner cache button', () => { + expect(findCleanCacheButton().text()).toBe('Clear runner caches'); }); it('renders empty state', () => { - expect(findBlankState().text()).toBe('There are currently no pipelines.'); + expect(findEmptyState().text()).toBe('There are currently no pipelines.'); + }); + + it('renders filtered search', () => { + expect(findFilteredSearch().exists()).toBe(true); }); it('renders tab empty state finished scope', async () => { @@ -528,7 +529,7 @@ describe('Pipelines', () => { await waitForPromises(); - expect(findBlankState().text()).toBe('There are currently no finished pipelines.'); + expect(findEmptyState().text()).toBe('There are currently no finished pipelines.'); }); }); @@ -550,6 +551,10 @@ describe('Pipelines', () => { ); }); + it('does not render filtered search', () => { + expect(findFilteredSearch().exists()).toBe(false); + }); + it('does not render tabs nor buttons', () => { expect(findNavigationTabs().exists()).toBe(false); expect(findTab('all').exists()).toBe(false); @@ -599,7 +604,7 @@ describe('Pipelines', () => { }); it('renders empty state', () => { - expect(findBlankState().text()).toBe('There are currently no pipelines.'); + expect(findEmptyState().text()).toBe('There are currently no pipelines.'); }); }); }); @@ -688,7 +693,7 @@ describe('Pipelines', () => { }); it('shows error state', () => { - expect(findBlankState().text()).toBe( + expect(findEmptyState().text()).toBe( 'There was an error fetching the pipelines. Try again in a few moments or contact your support team.', ); }); @@ -709,11 +714,11 @@ describe('Pipelines', () => { expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); - expect(findCleanCacheButton().text()).toBe('Clear Runner Caches'); + expect(findCleanCacheButton().text()).toBe('Clear runner caches'); }); it('shows error state', () => { - expect(findBlankState().text()).toBe( + expect(findEmptyState().text()).toBe( 'There was an error fetching the pipelines. Try again in a few moments or contact your support team.', ); }); diff --git a/spec/frontend/pipelines/pipelines_table_row_spec.js b/spec/frontend/pipelines/pipelines_table_row_spec.js deleted file mode 100644 index 68d46575081..00000000000 --- a/spec/frontend/pipelines/pipelines_table_row_spec.js +++ /dev/null @@ -1,239 +0,0 @@ -import { mount } from '@vue/test-utils'; -import waitForPromises from 'helpers/wait_for_promises'; -import PipelinesTableRowComponent from '~/pipelines/components/pipelines_list/pipelines_table_row.vue'; -import eventHub from '~/pipelines/event_hub'; - -describe('Pipelines Table Row', () => { - const jsonFixtureName = 'pipelines/pipelines.json'; - - const createWrapper = (pipeline) => - mount(PipelinesTableRowComponent, { - propsData: { - pipeline, - viewType: 'root', - }, - }); - - let wrapper; - let pipeline; - let pipelineWithoutAuthor; - let pipelineWithoutCommit; - - beforeEach(() => { - const { pipelines } = getJSONFixture(jsonFixtureName); - - pipeline = pipelines.find((p) => p.user !== null && p.commit !== null); - pipelineWithoutAuthor = pipelines.find((p) => p.user === null && p.commit !== null); - pipelineWithoutCommit = pipelines.find((p) => p.user === null && p.commit === null); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('should render a table row', () => { - wrapper = createWrapper(pipeline); - - expect(wrapper.attributes('class')).toContain('gl-responsive-table-row'); - }); - - describe('status column', () => { - beforeEach(() => { - wrapper = createWrapper(pipeline); - }); - - it('should render a pipeline link', () => { - expect(wrapper.find('.table-section.commit-link a').attributes('href')).toEqual( - pipeline.path, - ); - }); - - it('should render status text', () => { - expect(wrapper.find('.table-section.commit-link a').text()).toContain( - pipeline.details.status.text, - ); - }); - }); - - describe('information column', () => { - beforeEach(() => { - wrapper = createWrapper(pipeline); - }); - - it('should render a pipeline link', () => { - expect(wrapper.find('.table-section:nth-child(2) a').attributes('href')).toEqual( - pipeline.path, - ); - }); - - it('should render pipeline ID', () => { - expect(wrapper.find('.table-section:nth-child(2) a > span').text()).toEqual( - `#${pipeline.id}`, - ); - }); - - describe('when a user is provided', () => { - it('should render user information', () => { - expect( - wrapper.find('.table-section:nth-child(3) .js-pipeline-url-user').attributes('href'), - ).toEqual(pipeline.user.path); - - expect( - wrapper.find('.table-section:nth-child(3) .js-user-avatar-image-tooltip').text().trim(), - ).toEqual(pipeline.user.name); - }); - }); - }); - - describe('commit column', () => { - it('should render link to commit', () => { - wrapper = createWrapper(pipeline); - - const commitLink = wrapper.find('.branch-commit .commit-sha'); - - expect(commitLink.attributes('href')).toEqual(pipeline.commit.commit_path); - }); - - const findElements = () => { - const commitTitleElement = wrapper.find('.branch-commit .commit-title'); - const commitAuthorElement = commitTitleElement.find('a.avatar-image-container'); - - if (!commitAuthorElement.exists()) { - return { - commitAuthorElement, - }; - } - - const commitAuthorLink = commitAuthorElement.attributes('href'); - const commitAuthorName = commitAuthorElement - .find('.js-user-avatar-image-tooltip') - .text() - .trim(); - - return { - commitAuthorElement, - commitAuthorLink, - commitAuthorName, - }; - }; - - it('renders nothing without commit', () => { - expect(pipelineWithoutCommit.commit).toBe(null); - - wrapper = createWrapper(pipelineWithoutCommit); - const { commitAuthorElement } = findElements(); - - expect(commitAuthorElement.exists()).toBe(false); - }); - - it('renders commit author', () => { - wrapper = createWrapper(pipeline); - const { commitAuthorLink, commitAuthorName } = findElements(); - - expect(commitAuthorLink).toEqual(pipeline.commit.author.path); - expect(commitAuthorName).toEqual(pipeline.commit.author.username); - }); - - it('renders commit with unregistered author', () => { - expect(pipelineWithoutAuthor.commit.author).toBe(null); - - wrapper = createWrapper(pipelineWithoutAuthor); - const { commitAuthorLink, commitAuthorName } = findElements(); - - expect(commitAuthorLink).toEqual(`mailto:${pipelineWithoutAuthor.commit.author_email}`); - expect(commitAuthorName).toEqual(pipelineWithoutAuthor.commit.author_name); - }); - }); - - describe('stages column', () => { - const findAllMiniPipelineStages = () => - wrapper.findAll('.table-section:nth-child(5) [data-testid="mini-pipeline-graph-dropdown"]'); - - it('should render an icon for each stage', () => { - wrapper = createWrapper(pipeline); - - expect(findAllMiniPipelineStages()).toHaveLength(pipeline.details.stages.length); - }); - - it('should not render stages when stages are empty', () => { - const withoutStages = { ...pipeline }; - withoutStages.details = { ...withoutStages.details, stages: null }; - - wrapper = createWrapper(withoutStages); - - expect(findAllMiniPipelineStages()).toHaveLength(0); - }); - }); - - describe('actions column', () => { - const scheduledJobAction = { - name: 'some scheduled job', - }; - - beforeEach(() => { - const withActions = { ...pipeline }; - withActions.details.scheduled_actions = [scheduledJobAction]; - withActions.flags.cancelable = true; - withActions.flags.retryable = true; - withActions.cancel_path = '/cancel'; - withActions.retry_path = '/retry'; - - wrapper = createWrapper(withActions); - }); - - it('should render the provided actions', () => { - expect(wrapper.find('.js-pipelines-retry-button').exists()).toBe(true); - expect(wrapper.find('.js-pipelines-retry-button').attributes('title')).toMatch('Retry'); - expect(wrapper.find('.js-pipelines-cancel-button').exists()).toBe(true); - expect(wrapper.find('.js-pipelines-cancel-button').attributes('title')).toMatch('Cancel'); - }); - - it('should render the manual actions', async () => { - const manualActions = wrapper.find('[data-testid="pipelines-manual-actions-dropdown"]'); - - // Click on the dropdown and wait for `lazy` dropdown items - manualActions.find('.dropdown-toggle').trigger('click'); - await waitForPromises(); - - expect(manualActions.text()).toContain(scheduledJobAction.name); - }); - - it('emits `retryPipeline` event when retry button is clicked and toggles loading', () => { - eventHub.$on('retryPipeline', (endpoint) => { - expect(endpoint).toBe('/retry'); - }); - - wrapper.find('.js-pipelines-retry-button').trigger('click'); - expect(wrapper.vm.isRetrying).toBe(true); - }); - - it('emits `openConfirmationModal` event when cancel button is clicked and toggles loading', () => { - eventHub.$once('openConfirmationModal', (data) => { - const { id, ref, commit } = pipeline; - - expect(data.endpoint).toBe('/cancel'); - expect(data.pipeline).toEqual( - expect.objectContaining({ - id, - ref, - commit, - }), - ); - }); - - wrapper.find('.js-pipelines-cancel-button').trigger('click'); - }); - - it('renders a loading icon when `cancelingPipeline` matches pipeline id', (done) => { - wrapper.setProps({ cancelingPipeline: pipeline.id }); - wrapper.vm - .$nextTick() - .then(() => { - expect(wrapper.vm.isCancelling).toBe(true); - }) - .then(done) - .catch(done.fail); - }); - }); -}); diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js index 952bea81052..70e47b98575 100644 --- a/spec/frontend/pipelines/pipelines_table_spec.js +++ b/spec/frontend/pipelines/pipelines_table_spec.js @@ -30,23 +30,17 @@ describe('Pipelines Table', () => { return pipelines.find((p) => p.user !== null && p.commit !== null); }; - const createComponent = (props = {}, flagState = false) => { + const createComponent = (props = {}) => { wrapper = extendedWrapper( mount(PipelinesTable, { propsData: { ...defaultProps, ...props, }, - provide: { - glFeatures: { - newPipelinesTable: flagState, - }, - }, }), ); }; - const findRows = () => wrapper.findAll('.commit.gl-responsive-table-row'); const findGlTable = () => wrapper.findComponent(GlTable); const findStatusBadge = () => wrapper.findComponent(PipelinesStatusBadge); const findPipelineInfo = () => wrapper.findComponent(PipelineUrl); @@ -56,8 +50,7 @@ describe('Pipelines Table', () => { const findTimeAgo = () => wrapper.findComponent(PipelinesTimeago); const findActions = () => wrapper.findComponent(PipelineOperations); - const findLegacyTable = () => wrapper.findByTestId('legacy-ci-table'); - const findTableRows = () => wrapper.findAll('[data-testid="pipeline-table-row"]'); + const findTableRows = () => wrapper.findAllByTestId('pipeline-table-row'); const findStatusTh = () => wrapper.findByTestId('status-th'); const findPipelineTh = () => wrapper.findByTestId('pipeline-th'); const findTriggererTh = () => wrapper.findByTestId('triggerer-th'); @@ -75,52 +68,13 @@ describe('Pipelines Table', () => { wrapper = null; }); - describe('table with feature flag off', () => { - describe('renders the table correctly', () => { - beforeEach(() => { - createComponent(); - }); - - it('should render a table', () => { - expect(wrapper.classes()).toContain('ci-table'); - }); - - it('should render table head with correct columns', () => { - expect(wrapper.find('.table-section.js-pipeline-status').text()).toEqual('Status'); - - expect(wrapper.find('.table-section.js-pipeline-info').text()).toEqual('Pipeline'); - - expect(wrapper.find('.table-section.js-pipeline-commit').text()).toEqual('Commit'); - - expect(wrapper.find('.table-section.js-pipeline-stages').text()).toEqual('Stages'); - }); - }); - - describe('without data', () => { - it('should render an empty table', () => { - createComponent(); - - expect(findRows()).toHaveLength(0); - }); - }); - - describe('with data', () => { - it('should render rows', () => { - createComponent({ pipelines: [pipeline], viewType: 'root' }); - - expect(findRows()).toHaveLength(1); - }); - }); - }); - - describe('table with feature flag on', () => { + describe('Pipelines Table', () => { beforeEach(() => { - createComponent({ pipelines: [pipeline], viewType: 'root' }, true); + createComponent({ pipelines: [pipeline], viewType: 'root' }); }); - it('displays new table', () => { + it('displays table', () => { expect(findGlTable().exists()).toBe(true); - expect(findLegacyTable().exists()).toBe(false); }); it('should render table head with correct columns', () => { diff --git a/spec/frontend/pipelines/time_ago_spec.js b/spec/frontend/pipelines/time_ago_spec.js index 93aeb049434..3de7995b476 100644 --- a/spec/frontend/pipelines/time_ago_spec.js +++ b/spec/frontend/pipelines/time_ago_spec.js @@ -1,25 +1,33 @@ import { GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import TimeAgo from '~/pipelines/components/pipelines_list/time_ago.vue'; describe('Timeago component', () => { let wrapper; - const createComponent = (props = {}) => { - wrapper = shallowMount(TimeAgo, { - propsData: { - pipeline: { - details: { - ...props, + const defaultProps = { duration: 0, finished_at: '' }; + + const createComponent = (props = defaultProps, stuck = false) => { + wrapper = extendedWrapper( + shallowMount(TimeAgo, { + propsData: { + pipeline: { + details: { + ...props, + }, + flags: { + stuck, + }, }, }, - }, - data() { - return { - iconTimerSvg: `<svg></svg>`, - }; - }, - }); + data() { + return { + iconTimerSvg: `<svg></svg>`, + }; + }, + }), + ); }; afterEach(() => { @@ -29,7 +37,10 @@ describe('Timeago component', () => { const duration = () => wrapper.find('.duration'); const finishedAt = () => wrapper.find('.finished-at'); - const findInProgress = () => wrapper.find('[data-testid="pipeline-in-progress"]'); + const findInProgress = () => wrapper.findByTestId('pipeline-in-progress'); + const findSkipped = () => wrapper.findByTestId('pipeline-skipped'); + const findHourGlassIcon = () => wrapper.findByTestId('hourglass-icon'); + const findWarningIcon = () => wrapper.findByTestId('warning-icon'); describe('with duration', () => { beforeEach(() => { @@ -46,7 +57,7 @@ describe('Timeago component', () => { describe('without duration', () => { beforeEach(() => { - createComponent({ duration: 0, finished_at: '' }); + createComponent(); }); it('should not render duration and timer svg', () => { @@ -71,7 +82,7 @@ describe('Timeago component', () => { describe('without finishedTime', () => { beforeEach(() => { - createComponent({ duration: 0, finished_at: '' }); + createComponent(); }); it('should not render time and calendar icon', () => { @@ -89,10 +100,34 @@ describe('Timeago component', () => { `( 'progress state shown: $shouldShow when pipeline duration is $durationTime and finished_at is $finishedAtTime', ({ durationTime, finishedAtTime, shouldShow }) => { - createComponent({ duration: durationTime, finished_at: finishedAtTime }); + createComponent({ + duration: durationTime, + finished_at: finishedAtTime, + }); expect(findInProgress().exists()).toBe(shouldShow); + expect(findSkipped().exists()).toBe(false); }, ); + + it('should show warning icon beside in progress if pipeline is stuck', () => { + const stuck = true; + + createComponent(defaultProps, stuck); + + expect(findWarningIcon().exists()).toBe(true); + expect(findHourGlassIcon().exists()).toBe(false); + }); + }); + + describe('skipped', () => { + it('should show skipped if pipeline was skipped', () => { + createComponent({ + status: { label: 'skipped' }, + }); + + expect(findSkipped().exists()).toBe(true); + expect(findInProgress().exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/pipelines/unwrapping_utils_spec.js b/spec/frontend/pipelines/unwrapping_utils_spec.js index cd16ed7262e..a6ce7d4049f 100644 --- a/spec/frontend/pipelines/unwrapping_utils_spec.js +++ b/spec/frontend/pipelines/unwrapping_utils_spec.js @@ -96,11 +96,11 @@ const completeMock = [ describe('Shared pipeline unwrapping utils', () => { describe('unwrapGroups', () => { it('takes stages without nodes and returns the unwrapped groups', () => { - expect(unwrapGroups(stagesAndGroups)[0].groups).toEqual(groupsArray); + expect(unwrapGroups(stagesAndGroups)[0].node.groups).toEqual(groupsArray); }); it('keeps other stage properties intact', () => { - expect(unwrapGroups(stagesAndGroups)[0]).toMatchObject(basicStageInfo); + expect(unwrapGroups(stagesAndGroups)[0].node).toMatchObject(basicStageInfo); }); }); diff --git a/spec/frontend/projects/commit/components/branches_dropdown_spec.js b/spec/frontend/projects/commit/components/branches_dropdown_spec.js index 7686c28c7fc..ab84c3768d0 100644 --- a/spec/frontend/projects/commit/components/branches_dropdown_spec.js +++ b/spec/frontend/projects/commit/components/branches_dropdown_spec.js @@ -15,7 +15,7 @@ describe('BranchesDropdown', () => { const createComponent = (term, state = { isFetching: false }) => { store = new Vuex.Store({ getters: { - joinedBranches: () => ['_master_', '_branch_1_', '_branch_2_'], + joinedBranches: () => ['_main_', '_branch_1_', '_branch_2_'], }, actions: { fetchBranches: spyFetchBranches, @@ -94,13 +94,13 @@ describe('BranchesDropdown', () => { it('renders all branches when search term is empty', () => { expect(findAllDropdownItems()).toHaveLength(3); - expect(findDropdownItemByIndex(0).text()).toBe('_master_'); + expect(findDropdownItemByIndex(0).text()).toBe('_main_'); expect(findDropdownItemByIndex(1).text()).toBe('_branch_1_'); expect(findDropdownItemByIndex(2).text()).toBe('_branch_2_'); }); it('should not be selected on the inactive branch', () => { - expect(wrapper.vm.isSelected('_master_')).toBe(false); + expect(wrapper.vm.isSelected('_main_')).toBe(false); }); }); diff --git a/spec/frontend/projects/commit/components/commit_comments_button_spec.js b/spec/frontend/projects/commit/components/commit_comments_button_spec.js new file mode 100644 index 00000000000..873270c5be1 --- /dev/null +++ b/spec/frontend/projects/commit/components/commit_comments_button_spec.js @@ -0,0 +1,42 @@ +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import CommitCommentsButton from '~/projects/commit/components/commit_comments_button.vue'; + +describe('CommitCommentsButton', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = extendedWrapper( + shallowMount(CommitCommentsButton, { + propsData: { + commentsCount: 1, + ...props, + }, + }), + ); + }; + + const tooltip = () => wrapper.findByTestId('comment-button-wrapper'); + + describe('Comment Button', () => { + it('has proper tooltip and button attributes for 1 comment', () => { + createComponent(); + + expect(tooltip().attributes('title')).toBe('1 comment on this commit'); + expect(tooltip().text()).toBe('1'); + }); + + it('has proper tooltip and button attributes for multiple comments', () => { + createComponent({ commentsCount: 2 }); + + expect(tooltip().attributes('title')).toBe('2 comments on this commit'); + expect(tooltip().text()).toBe('2'); + }); + + it('does not show when there are no comments', () => { + createComponent({ commentsCount: 0 }); + + expect(tooltip().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/projects/commit/components/commit_options_dropdown_spec.js b/spec/frontend/projects/commit/components/commit_options_dropdown_spec.js new file mode 100644 index 00000000000..70491405986 --- /dev/null +++ b/spec/frontend/projects/commit/components/commit_options_dropdown_spec.js @@ -0,0 +1,123 @@ +import { GlDropdownDivider, GlDropdownSectionHeader } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import CommitOptionsDropdown from '~/projects/commit/components/commit_options_dropdown.vue'; +import { OPEN_REVERT_MODAL, OPEN_CHERRY_PICK_MODAL } from '~/projects/commit/constants'; +import eventHub from '~/projects/commit/event_hub'; + +describe('BranchesDropdown', () => { + let wrapper; + const provide = { + newProjectTagPath: '_new_project_tag_path_', + emailPatchesPath: '_email_patches_path_', + plainDiffPath: '_plain_diff_path_', + }; + + const createComponent = (props = {}) => { + wrapper = extendedWrapper( + shallowMount(CommitOptionsDropdown, { + provide, + propsData: { + canRevert: true, + canCherryPick: true, + canTag: true, + canEmailPatches: true, + ...props, + }, + }), + ); + }; + + const findRevertLink = () => wrapper.findByTestId('revert-link'); + const findCherryPickLink = () => wrapper.findByTestId('cherry-pick-link'); + const findTagItem = () => wrapper.findByTestId('tag-link'); + const findEmailPatchesItem = () => wrapper.findByTestId('email-patches-link'); + const findPlainDiffItem = () => wrapper.findByTestId('plain-diff-link'); + const findDivider = () => wrapper.findComponent(GlDropdownDivider); + const findSectionHeader = () => wrapper.findComponent(GlDropdownSectionHeader); + + describe('Everything enabled', () => { + beforeEach(() => { + createComponent(); + }); + + it('has expected dropdown button text', () => { + expect(wrapper.attributes('text')).toBe('Options'); + }); + + it('has expected items', () => { + expect( + [ + findRevertLink().exists(), + findCherryPickLink().exists(), + findTagItem().exists(), + findDivider().exists(), + findSectionHeader().exists(), + findEmailPatchesItem().exists(), + findPlainDiffItem().exists(), + ].every((exists) => exists), + ).toBe(true); + }); + + it('has expected href links', () => { + expect(findTagItem().attributes('href')).toBe(provide.newProjectTagPath); + expect(findEmailPatchesItem().attributes('href')).toBe(provide.emailPatchesPath); + expect(findPlainDiffItem().attributes('href')).toBe(provide.plainDiffPath); + }); + }); + + describe('Different dropdown item permutations', () => { + it('does not have a revert option', () => { + createComponent({ canRevert: false }); + + expect(findRevertLink().exists()).toBe(false); + }); + + it('does not have a cherry-pick option', () => { + createComponent({ canCherryPick: false }); + + expect(findCherryPickLink().exists()).toBe(false); + }); + + it('does not have a tag option', () => { + createComponent({ canTag: false }); + + expect(findTagItem().exists()).toBe(false); + }); + + it('does not have a email patches options', () => { + createComponent({ canEmailPatches: false }); + + expect(findEmailPatchesItem().exists()).toBe(false); + }); + + it('only has the download items', () => { + createComponent({ canRevert: false, canCherryPick: false, canTag: false }); + + expect(findDivider().exists()).toBe(false); + expect(findEmailPatchesItem().exists()).toBe(true); + expect(findPlainDiffItem().exists()).toBe(true); + }); + }); + + describe('Modal triggering', () => { + let spy; + + beforeEach(() => { + spy = jest.spyOn(eventHub, '$emit'); + createComponent(); + }); + + it('emits openModal for revert', () => { + findRevertLink().vm.$emit('click'); + + expect(spy).toHaveBeenCalledWith(OPEN_REVERT_MODAL); + }); + + it('emits openModal for cherry-pick', () => { + findCherryPickLink().vm.$emit('click'); + + expect(spy).toHaveBeenCalledWith(OPEN_CHERRY_PICK_MODAL); + }); + }); +}); diff --git a/spec/frontend/projects/commit/components/form_modal_spec.js b/spec/frontend/projects/commit/components/form_modal_spec.js index 708644cb7ee..9688cb47799 100644 --- a/spec/frontend/projects/commit/components/form_modal_spec.js +++ b/spec/frontend/projects/commit/components/form_modal_spec.js @@ -17,15 +17,14 @@ describe('CommitFormModal', () => { let store; let axiosMock; - const createComponent = (method, state = {}, provide = {}) => { + const createComponent = (method, state = {}, provide = {}, propsData = {}) => { store = createStore({ ...mockData.mockModal, ...state }); wrapper = extendedWrapper( method(CommitFormModal, { provide: { ...provide, - glFeatures: { pickIntoProject: true }, }, - propsData: { ...mockData.modalPropsData }, + propsData: { ...mockData.modalPropsData, ...propsData }, store, attrs: { static: true, @@ -160,6 +159,12 @@ describe('CommitFormModal', () => { }); it('Changes the target_project_id input value', async () => { + createComponent( + shallowMount, + {}, + { glFeatures: { pickIntoProject: true } }, + { isCherryPick: true }, + ); findProjectsDropdown().vm.$emit('selectProject', '_changed_project_value_'); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/projects/commit/components/form_trigger_spec.js b/spec/frontend/projects/commit/components/form_trigger_spec.js deleted file mode 100644 index 4503493c0a6..00000000000 --- a/spec/frontend/projects/commit/components/form_trigger_spec.js +++ /dev/null @@ -1,44 +0,0 @@ -import { GlLink } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import FormTrigger from '~/projects/commit/components/form_trigger.vue'; -import eventHub from '~/projects/commit/event_hub'; - -const displayText = '_display_text_'; - -const createComponent = () => { - return shallowMount(FormTrigger, { - provide: { displayText }, - propsData: { openModal: '_open_modal_' }, - }); -}; - -describe('FormTrigger', () => { - let wrapper; - let spy; - - beforeEach(() => { - spy = jest.spyOn(eventHub, '$emit'); - wrapper = createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - const findLink = () => wrapper.find(GlLink); - - describe('displayText', () => { - it('includes the correct displayText for the link', () => { - expect(findLink().text()).toBe(displayText); - }); - }); - - describe('clicking the link', () => { - it('emits openModal', () => { - findLink().vm.$emit('click'); - - expect(spy).toHaveBeenCalledWith('_open_modal_'); - }); - }); -}); diff --git a/spec/frontend/projects/commit/mock_data.js b/spec/frontend/projects/commit/mock_data.js index e4dcb24c4c0..34e9c400af4 100644 --- a/spec/frontend/projects/commit/mock_data.js +++ b/spec/frontend/projects/commit/mock_data.js @@ -23,6 +23,6 @@ export default { modalId: '_modal_id_', openModal: '_open_modal_', }, - mockBranches: ['_branch_1', '_abc_', '_master_'], + mockBranches: ['_branch_1', '_abc_', '_main_'], mockProjects: ['_project_1', '_abc_', '_project_'], }; diff --git a/spec/frontend/projects/commit/store/mutations_spec.js b/spec/frontend/projects/commit/store/mutations_spec.js index 8989e769772..60abf0fddad 100644 --- a/spec/frontend/projects/commit/store/mutations_spec.js +++ b/spec/frontend/projects/commit/store/mutations_spec.js @@ -27,7 +27,7 @@ describe('Commit form modal mutations', () => { describe('CLEAR_MODAL', () => { it('should clear modal state ', () => { - stateCopy = { branch: '_master_', defaultBranch: '_default_branch_' }; + stateCopy = { branch: '_main_', defaultBranch: '_default_branch_' }; mutations[types.CLEAR_MODAL](stateCopy); @@ -47,7 +47,7 @@ describe('Commit form modal mutations', () => { describe('SET_BRANCH', () => { it('should set branch', () => { - stateCopy = { branch: '_master_' }; + stateCopy = { branch: '_main_' }; mutations[types.SET_BRANCH](stateCopy, '_changed_branch_'); @@ -57,7 +57,7 @@ describe('Commit form modal mutations', () => { describe('SET_SELECTED_BRANCH', () => { it('should set selectedBranch', () => { - stateCopy = { selectedBranch: '_master_' }; + stateCopy = { selectedBranch: '_main_' }; mutations[types.SET_SELECTED_BRANCH](stateCopy, '_changed_branch_'); diff --git a/spec/frontend/projects/commit_box/info/load_branches_spec.js b/spec/frontend/projects/commit_box/info/load_branches_spec.js index 8100200cbdd..9456e6ef5f5 100644 --- a/spec/frontend/projects/commit_box/info/load_branches_spec.js +++ b/spec/frontend/projects/commit_box/info/load_branches_spec.js @@ -1,66 +1,73 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import { setHTMLFixture } from 'helpers/fixtures'; import waitForPromises from 'helpers/wait_for_promises'; import { loadBranches } from '~/projects/commit_box/info/load_branches'; const mockCommitPath = '/commit/abcd/branches'; const mockBranchesRes = - '<a href="/-/commits/master">master</a><span><a href="/-/commits/my-branch">my-branch</a></span>'; + '<a href="/-/commits/main">main</a><span><a href="/-/commits/my-branch">my-branch</a></span>'; describe('~/projects/commit_box/info/load_branches', () => { let mock; - let el; + + const getElInnerHtml = () => document.querySelector('.js-commit-box-info').innerHTML; beforeEach(() => { + setHTMLFixture(` + <div class="js-commit-box-info" data-commit-path="${mockCommitPath}"> + <div class="commit-info branches"> + <span class="spinner"/> + </div> + </div>`); + mock = new MockAdapter(axios); mock.onGet(mockCommitPath).reply(200, mockBranchesRes); - - el = document.createElement('div'); - el.dataset.commitPath = mockCommitPath; - el.innerHTML = '<div class="commit-info branches"><span class="spinner"/></div>'; }); it('loads and renders branches info', async () => { - loadBranches(el); + loadBranches(); await waitForPromises(); - expect(el.innerHTML).toBe(`<div class="commit-info branches">${mockBranchesRes}</div>`); + expect(getElInnerHtml()).toMatchInterpolatedText( + `<div class="commit-info branches">${mockBranchesRes}</div>`, + ); }); it('does not load when no container is provided', async () => { - loadBranches(null); + loadBranches('.js-another-class'); await waitForPromises(); expect(mock.history.get).toHaveLength(0); }); - describe('when braches request returns unsafe content', () => { + describe('when branches request returns unsafe content', () => { beforeEach(() => { mock .onGet(mockCommitPath) - .reply(200, '<a onload="alert(\'xss!\');" href="/-/commits/master">master</a>'); + .reply(200, '<a onload="alert(\'xss!\');" href="/-/commits/main">main</a>'); }); it('displays sanitized html', async () => { - loadBranches(el); + loadBranches(); await waitForPromises(); - expect(el.innerHTML).toBe( - '<div class="commit-info branches"><a href="/-/commits/master">master</a></div>', + expect(getElInnerHtml()).toMatchInterpolatedText( + '<div class="commit-info branches"><a href="/-/commits/main">main</a></div>', ); }); }); - describe('when braches request fails', () => { + describe('when branches request fails', () => { beforeEach(() => { mock.onGet(mockCommitPath).reply(500, 'Error!'); }); it('attempts to load and renders an error', async () => { - loadBranches(el); + loadBranches(); await waitForPromises(); - expect(el.innerHTML).toBe( + expect(getElInnerHtml()).toMatchInterpolatedText( '<div class="commit-info branches">Failed to load branches. Please try again.</div>', ); }); diff --git a/spec/frontend/projects/compare/components/app_legacy_spec.js b/spec/frontend/projects/compare/components/app_legacy_spec.js index 4c7f0d5cccc..93e96c8b9f7 100644 --- a/spec/frontend/projects/compare/components/app_legacy_spec.js +++ b/spec/frontend/projects/compare/components/app_legacy_spec.js @@ -8,7 +8,7 @@ jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); const projectCompareIndexPath = 'some/path'; const refsProjectPath = 'some/refs/path'; const paramsFrom = 'master'; -const paramsTo = 'master'; +const paramsTo = 'some-other-branch'; describe('CompareApp component', () => { let wrapper; @@ -36,6 +36,9 @@ describe('CompareApp component', () => { createComponent(); }); + const findSourceDropdown = () => wrapper.find('[data-testid="sourceRevisionDropdown"]'); + const findTargetDropdown = () => wrapper.find('[data-testid="targetRevisionDropdown"]'); + it('renders component with prop', () => { expect(wrapper.props()).toEqual( expect.objectContaining({ @@ -62,12 +65,31 @@ describe('CompareApp component', () => { expect(wrapper.find('[data-testid="ellipsis"]').exists()).toBe(true); }); - it('render Source and Target BranchDropdown components', () => { - const branchDropdowns = wrapper.findAll(RevisionDropdown); + describe('Source and Target BranchDropdown components', () => { + const findAllBranchDropdowns = () => wrapper.findAll(RevisionDropdown); + + it('renders the components with the correct props', () => { + expect(findAllBranchDropdowns().length).toBe(2); + expect(findSourceDropdown().props('revisionText')).toBe('Source'); + expect(findTargetDropdown().props('revisionText')).toBe('Target'); + }); + + it('sets the revision when the "selectRevision" event is emitted', async () => { + findSourceDropdown().vm.$emit('selectRevision', { + direction: 'to', + revision: 'some-source-revision', + }); + + findTargetDropdown().vm.$emit('selectRevision', { + direction: 'from', + revision: 'some-target-revision', + }); + + await wrapper.vm.$nextTick(); - expect(branchDropdowns.length).toBe(2); - expect(branchDropdowns.at(0).props('revisionText')).toBe('Source'); - expect(branchDropdowns.at(1).props('revisionText')).toBe('Target'); + expect(findTargetDropdown().props('paramsBranch')).toBe('some-target-revision'); + expect(findSourceDropdown().props('paramsBranch')).toBe('some-source-revision'); + }); }); describe('compare button', () => { @@ -87,6 +109,27 @@ describe('CompareApp component', () => { }); }); + describe('swap revisions button', () => { + const findSwapRevisionsButton = () => wrapper.find('[data-testid="swapRevisionsButton"]'); + + it('renders the swap revisions button', () => { + expect(findSwapRevisionsButton().exists()).toBe(true); + }); + + it('has the correct text', () => { + expect(findSwapRevisionsButton().text()).toBe('Swap revisions'); + }); + + it('swaps revisions when clicked', async () => { + findSwapRevisionsButton().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + expect(findTargetDropdown().props('paramsBranch')).toBe(paramsTo); + expect(findSourceDropdown().props('paramsBranch')).toBe(paramsFrom); + }); + }); + describe('merge request buttons', () => { const findProjectMrButton = () => wrapper.find('[data-testid="projectMrButton"]'); const findCreateMrButton = () => wrapper.find('[data-testid="createMrButton"]'); diff --git a/spec/frontend/projects/compare/components/repo_dropdown_spec.js b/spec/frontend/projects/compare/components/repo_dropdown_spec.js index af76632515c..df8fea8fd32 100644 --- a/spec/frontend/projects/compare/components/repo_dropdown_spec.js +++ b/spec/frontend/projects/compare/components/repo_dropdown_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import RepoDropdown from '~/projects/compare/components/repo_dropdown.vue'; @@ -69,12 +69,12 @@ describe('RepoDropdown component', () => { createComponent({ paramsName: 'from' }); }); - it('set hidden input of the first project', () => { - expect(findHiddenInput().attributes('value')).toBe(projectFromId); + it('set hidden input of the selected project', () => { + expect(findHiddenInput().attributes('value')).toBe(projectToId); }); - it('displays the first project name initially in the dropdown', () => { - expect(findGlDropdown().props('text')).toBe(projectFromName); + it('displays matching project name of the source revision initially in the dropdown', () => { + expect(findGlDropdown().props('text')).toBe(projectToName); }); it('updates the hiddin input value when onClick method is triggered', async () => { @@ -84,15 +84,13 @@ describe('RepoDropdown component', () => { expect(findHiddenInput().attributes('value')).toBe(repoId); }); - it('emits initial `changeTargetProject` event with target project', () => { - expect(wrapper.emitted('changeTargetProject')).toEqual([[projectFromName]]); - }); - it('emits `changeTargetProject` event when another target project is selected', async () => { - const newTargetProject = 'new-from-name'; - wrapper.vm.$emit('changeTargetProject', newTargetProject); + const index = 1; + const { projectsFrom } = defaultProvide; + findGlDropdown().findAll(GlDropdownItem).at(index).vm.$emit('click'); await wrapper.vm.$nextTick(); - expect(wrapper.emitted('changeTargetProject')[1]).toEqual([newTargetProject]); + + expect(wrapper.emitted('changeTargetProject')[0][0]).toEqual(projectsFrom[index].name); }); }); }); diff --git a/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js b/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js index 270c89e674c..ca208395e82 100644 --- a/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js +++ b/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import createFlash from '~/flash'; @@ -29,6 +29,7 @@ describe('RevisionDropdown component', () => { beforeEach(() => { axiosMock = new AxiosMockAdapter(axios); + createComponent(); }); afterEach(() => { @@ -39,7 +40,6 @@ describe('RevisionDropdown component', () => { const findGlDropdown = () => wrapper.find(GlDropdown); it('sets hidden input', () => { - createComponent(); expect(wrapper.find('input[type="hidden"]').attributes('value')).toBe( defaultProps.paramsBranch, ); @@ -68,8 +68,6 @@ describe('RevisionDropdown component', () => { Tags: undefined, }); - createComponent(); - await axios.waitForAll(); expect(wrapper.vm.branches).toEqual([]); @@ -79,15 +77,12 @@ describe('RevisionDropdown component', () => { it('shows flash message on error', async () => { axiosMock.onGet('some/invalid/path').replyOnce(404); - createComponent(); - await wrapper.vm.fetchBranchesAndTags(); expect(createFlash).toHaveBeenCalled(); }); describe('GlDropdown component', () => { it('renders props', () => { - createComponent(); expect(wrapper.props()).toEqual(expect.objectContaining(defaultProps)); }); @@ -99,8 +94,22 @@ describe('RevisionDropdown component', () => { }); it('display params branch text', () => { - createComponent(); expect(findGlDropdown().props('text')).toBe(defaultProps.paramsBranch); }); + + it('emits a "selectRevision" event when a revision is selected', async () => { + const findGlDropdownItems = () => wrapper.findAll(GlDropdownItem); + const findFirstGlDropdownItem = () => findGlDropdownItems().at(0); + + wrapper.setData({ branches: ['some-branch'] }); + + await wrapper.vm.$nextTick(); + + findFirstGlDropdownItem().vm.$emit('click'); + + expect(wrapper.emitted()).toEqual({ + selectRevision: [[{ direction: 'from', revision: 'some-branch' }]], + }); + }); }); }); diff --git a/spec/frontend/projects/compare/components/revision_dropdown_spec.js b/spec/frontend/projects/compare/components/revision_dropdown_spec.js index 69d3167c99c..aab9607ceae 100644 --- a/spec/frontend/projects/compare/components/revision_dropdown_spec.js +++ b/spec/frontend/projects/compare/components/revision_dropdown_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown } from '@gitlab/ui'; +import { GlDropdown, GlSearchBoxByType } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import createFlash from '~/flash'; @@ -23,6 +23,10 @@ describe('RevisionDropdown component', () => { ...defaultProps, ...props, }, + stubs: { + GlDropdown, + GlSearchBoxByType, + }, }); }; @@ -36,6 +40,7 @@ describe('RevisionDropdown component', () => { }); const findGlDropdown = () => wrapper.find(GlDropdown); + const findSearchBox = () => wrapper.find(GlSearchBoxByType); it('sets hidden input', () => { createComponent(); @@ -85,6 +90,40 @@ describe('RevisionDropdown component', () => { expect(axios.get).toHaveBeenLastCalledWith(newRefsProjectPath); }); + describe('search', () => { + it('shows flash message on error', async () => { + axiosMock.onGet('some/invalid/path').replyOnce(404); + + createComponent(); + + await wrapper.vm.searchBranchesAndTags(); + expect(createFlash).toHaveBeenCalled(); + }); + + it('makes request with search param', async () => { + jest.spyOn(axios, 'get').mockResolvedValue({ + data: { + Branches: [], + Tags: [], + }, + }); + + const mockSearchTerm = 'foobar'; + createComponent(); + findSearchBox().vm.$emit('input', mockSearchTerm); + await axios.waitForAll(); + + expect(axios.get).toHaveBeenCalledWith( + defaultProps.refsProjectPath, + expect.objectContaining({ + params: { + search: mockSearchTerm, + }, + }), + ); + }); + }); + describe('GlDropdown component', () => { it('renders props', () => { createComponent(); diff --git a/spec/frontend/projects/experiment_new_project_creation/components/app_spec.js b/spec/frontend/projects/experiment_new_project_creation/components/app_spec.js index b4ae50341d4..204e7a7c394 100644 --- a/spec/frontend/projects/experiment_new_project_creation/components/app_spec.js +++ b/spec/frontend/projects/experiment_new_project_creation/components/app_spec.js @@ -1,5 +1,6 @@ import { GlBreadcrumb } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { assignGitlabExperiment } from 'helpers/experimentation_helper'; import App from '~/projects/experiment_new_project_creation/components/app.vue'; import LegacyContainer from '~/projects/experiment_new_project_creation/components/legacy_container.vue'; import WelcomePage from '~/projects/experiment_new_project_creation/components/welcome.vue'; @@ -17,6 +18,57 @@ describe('Experimental new project creation app', () => { wrapper = null; }); + const findWelcomePage = () => wrapper.findComponent(WelcomePage); + const findPanel = (panelName) => + findWelcomePage() + .props() + .panels.find((p) => p.name === panelName); + const findPanelHeader = () => wrapper.find('h4'); + + describe('new_repo experiment', () => { + describe('when in the candidate variant', () => { + assignGitlabExperiment('new_repo', 'candidate'); + + it('has "repository" in the panel title', () => { + createComponent(); + + expect(findPanel('blank_project').title).toBe('Create blank project/repository'); + }); + + describe('when hash is not empty on load', () => { + beforeEach(() => { + window.location.hash = '#blank_project'; + createComponent(); + }); + + it('renders "project/repository"', () => { + expect(findPanelHeader().text()).toBe('Create blank project/repository'); + }); + }); + }); + + describe('when in the control variant', () => { + assignGitlabExperiment('new_repo', 'control'); + + it('has "project" in the panel title', () => { + createComponent(); + + expect(findPanel('blank_project').title).toBe('Create blank project'); + }); + + describe('when hash is not empty on load', () => { + beforeEach(() => { + window.location.hash = '#blank_project'; + createComponent(); + }); + + it('renders "project"', () => { + expect(findPanelHeader().text()).toBe('Create blank project'); + }); + }); + }); + }); + describe('with empty hash', () => { beforeEach(() => { createComponent(); diff --git a/spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js b/spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js index f26d1a6d2a3..9fd1230806e 100644 --- a/spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js +++ b/spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js @@ -1,8 +1,13 @@ import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import { mockTracking } from 'helpers/tracking_helper'; +import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; +import { getExperimentData } from '~/experimentation/utils'; import NewProjectPushTipPopover from '~/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue'; import WelcomePage from '~/projects/experiment_new_project_creation/components/welcome.vue'; +jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn() })); + describe('Welcome page', () => { let wrapper; let trackingSpy; @@ -14,6 +19,7 @@ describe('Welcome page', () => { beforeEach(() => { trackingSpy = mockTracking('_category_', document, jest.spyOn); trackingSpy.mockImplementation(() => {}); + getExperimentData.mockReturnValue(undefined); }); afterEach(() => { @@ -22,14 +28,35 @@ describe('Welcome page', () => { wrapper = null; }); - it('tracks link clicks', () => { + it('tracks link clicks', async () => { createComponent({ panels: [{ name: 'test', href: '#' }] }); - wrapper.find('a').trigger('click'); + const link = wrapper.find('a'); + link.trigger('click'); + await nextTick(); return wrapper.vm.$nextTick().then(() => { expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_tab', { label: 'test' }); }); }); + it('adds new_repo experiment data if in experiment', async () => { + const mockExperimentData = 'data'; + getExperimentData.mockReturnValue(mockExperimentData); + + createComponent({ panels: [{ name: 'test', href: '#' }] }); + const link = wrapper.find('a'); + link.trigger('click'); + await nextTick(); + return wrapper.vm.$nextTick().then(() => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_tab', { + label: 'test', + context: { + data: mockExperimentData, + schema: TRACKING_CONTEXT_SCHEMA, + }, + }); + }); + }); + it('renders new project push tip popover', () => { createComponent({ panels: [{ name: 'test', href: '#' }] }); diff --git a/spec/frontend/projects/pipelines/charts/components/__snapshots__/ci_cd_analytics_area_chart_spec.js.snap b/spec/frontend/projects/pipelines/charts/components/__snapshots__/ci_cd_analytics_area_chart_spec.js.snap index fc51825f15b..c37f6415898 100644 --- a/spec/frontend/projects/pipelines/charts/components/__snapshots__/ci_cd_analytics_area_chart_spec.js.snap +++ b/spec/frontend/projects/pipelines/charts/components/__snapshots__/ci_cd_analytics_area_chart_spec.js.snap @@ -21,7 +21,11 @@ exports[`CiCdAnalyticsAreaChart matches the snapshot 1`] = ` option="[object Object]" thresholds="" width="0" - /> + > + <template /> + + <template /> + </glareachart-stub> </div> </div> `; diff --git a/spec/frontend/projects/pipelines/charts/components/app_spec.js b/spec/frontend/projects/pipelines/charts/components/app_spec.js index e8aace14db4..0cf05d4ac37 100644 --- a/spec/frontend/projects/pipelines/charts/components/app_spec.js +++ b/spec/frontend/projects/pipelines/charts/components/app_spec.js @@ -10,6 +10,7 @@ import PipelineCharts from '~/projects/pipelines/charts/components/pipeline_char jest.mock('~/lib/utils/url_utility'); const DeploymentFrequencyChartsStub = { name: 'DeploymentFrequencyCharts', render: () => {} }; +const LeadTimeChartsStub = { name: 'LeadTimeCharts', render: () => {} }; describe('ProjectsPipelinesChartsApp', () => { let wrapper; @@ -21,10 +22,11 @@ describe('ProjectsPipelinesChartsApp', () => { {}, { provide: { - shouldRenderDeploymentFrequencyCharts: false, + shouldRenderDoraCharts: true, }, stubs: { DeploymentFrequencyCharts: DeploymentFrequencyChartsStub, + LeadTimeCharts: LeadTimeChartsStub, }, }, mountOptions, @@ -32,37 +34,42 @@ describe('ProjectsPipelinesChartsApp', () => { ); } - beforeEach(() => { - createComponent(); - }); - afterEach(() => { wrapper.destroy(); - wrapper = null; }); const findGlTabs = () => wrapper.find(GlTabs); - const findAllGlTab = () => wrapper.findAll(GlTab); - const findGlTabAt = (i) => findAllGlTab().at(i); + const findAllGlTabs = () => wrapper.findAll(GlTab); + const findGlTabAtIndex = (index) => findAllGlTabs().at(index); + const findLeadTimeCharts = () => wrapper.find(LeadTimeChartsStub); const findDeploymentFrequencyCharts = () => wrapper.find(DeploymentFrequencyChartsStub); const findPipelineCharts = () => wrapper.find(PipelineCharts); - it('renders the pipeline charts', () => { - expect(findPipelineCharts().exists()).toBe(true); - }); - - describe('when shouldRenderDeploymentFrequencyCharts is true', () => { + describe('when all charts are available', () => { beforeEach(() => { - createComponent({ provide: { shouldRenderDeploymentFrequencyCharts: true } }); + createComponent(); }); - it('renders the deployment frequency charts in a tab', () => { + it('renders tabs', () => { expect(findGlTabs().exists()).toBe(true); - expect(findGlTabAt(0).attributes('title')).toBe('Pipelines'); - expect(findGlTabAt(1).attributes('title')).toBe('Deployments'); + + expect(findGlTabAtIndex(0).attributes('title')).toBe('Pipelines'); + expect(findGlTabAtIndex(1).attributes('title')).toBe('Deployments'); + expect(findGlTabAtIndex(2).attributes('title')).toBe('Lead Time'); + }); + + it('renders the pipeline charts', () => { + expect(findPipelineCharts().exists()).toBe(true); + }); + + it('renders the deployment frequency charts', () => { expect(findDeploymentFrequencyCharts().exists()).toBe(true); }); + it('renders the lead time charts', () => { + expect(findLeadTimeCharts().exists()).toBe(true); + }); + it('sets the tab and url when a tab is clicked', async () => { let chartsPath; setWindowLocation(`${TEST_HOST}/gitlab-org/gitlab-test/-/pipelines/charts`); @@ -108,6 +115,7 @@ describe('ProjectsPipelinesChartsApp', () => { describe('when provided with a query param', () => { it.each` chart | tab + ${'lead-time'} | ${'2'} ${'deployments'} | ${'1'} ${'pipelines'} | ${'0'} ${'fake'} | ${'0'} @@ -118,7 +126,7 @@ describe('ProjectsPipelinesChartsApp', () => { expect(name).toBe('chart'); return chart ? [chart] : []; }); - createComponent({ provide: { shouldRenderDeploymentFrequencyCharts: true } }); + createComponent(); expect(findGlTabs().attributes('value')).toBe(tab); }); @@ -138,7 +146,7 @@ describe('ProjectsPipelinesChartsApp', () => { return []; }); - createComponent({ provide: { shouldRenderDeploymentFrequencyCharts: true } }); + createComponent(); expect(findGlTabs().attributes('value')).toBe('0'); @@ -155,14 +163,17 @@ describe('ProjectsPipelinesChartsApp', () => { }); }); - describe('when shouldRenderDeploymentFrequencyCharts is false', () => { + describe('when the dora charts are not available', () => { beforeEach(() => { - createComponent({ provide: { shouldRenderDeploymentFrequencyCharts: false } }); + createComponent({ provide: { shouldRenderDoraCharts: false } }); }); - it('does not render the deployment frequency charts in a tab', () => { + it('does not render tabs', () => { expect(findGlTabs().exists()).toBe(false); - expect(findDeploymentFrequencyCharts().exists()).toBe(false); + }); + + it('renders the pipeline charts', () => { + expect(findPipelineCharts().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/registry/explorer/pages/list_spec.js b/spec/frontend/registry/explorer/pages/list_spec.js index f7f207cc183..48acc06792d 100644 --- a/spec/frontend/registry/explorer/pages/list_spec.js +++ b/spec/frontend/registry/explorer/pages/list_spec.js @@ -1,9 +1,11 @@ import { GlSkeletonLoader, GlSprintf, GlAlert } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; +import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import DeleteImage from '~/registry/explorer/components/delete_image.vue'; import CliCommands from '~/registry/explorer/components/list_page/cli_commands.vue'; import GroupEmptyState from '~/registry/explorer/components/list_page/group_empty_state.vue'; @@ -60,7 +62,7 @@ describe('List Page', () => { const waitForApolloRequestRender = async () => { jest.runOnlyPendingTimers(); await waitForPromises(); - await wrapper.vm.$nextTick(); + await nextTick(); }; const mountComponent = ({ @@ -69,6 +71,7 @@ describe('List Page', () => { detailsResolver = jest.fn().mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock), mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock), config = { isGroupPage: false }, + query = {}, } = {}) => { localVue.use(VueApollo); @@ -95,6 +98,7 @@ describe('List Page', () => { $toast, $route: { name: 'foo', + query, }, ...mocks, }, @@ -158,9 +162,11 @@ describe('List Page', () => { }); describe('isLoading is true', () => { - it('shows the skeleton loader', () => { + it('shows the skeleton loader', async () => { mountComponent(); + await nextTick(); + expect(findSkeletonLoader().exists()).toBe(true); }); @@ -176,9 +182,11 @@ describe('List Page', () => { expect(findCliCommands().exists()).toBe(false); }); - it('title has the metadataLoading props set to true', () => { + it('title has the metadataLoading props set to true', async () => { mountComponent(); + await nextTick(); + expect(findRegistryHeader().props('metadataLoading')).toBe(true); }); }); @@ -311,7 +319,7 @@ describe('List Page', () => { await selectImageForDeletion(); findDeleteImage().vm.$emit('success'); - await wrapper.vm.$nextTick(); + await nextTick(); const alert = findDeleteAlert(); expect(alert.exists()).toBe(true); @@ -327,7 +335,7 @@ describe('List Page', () => { await selectImageForDeletion(); findDeleteImage().vm.$emit('error'); - await wrapper.vm.$nextTick(); + await nextTick(); const alert = findDeleteAlert(); expect(alert.exists()).toBe(true); @@ -343,12 +351,12 @@ describe('List Page', () => { const doSearch = async () => { await waitForApolloRequestRender(); findRegistrySearch().vm.$emit('filter:changed', [ - { type: 'filtered-search-term', value: { data: 'centos6' } }, + { type: FILTERED_SEARCH_TERM, value: { data: 'centos6' } }, ]); findRegistrySearch().vm.$emit('filter:submit'); - await wrapper.vm.$nextTick(); + await nextTick(); }; it('has a search box element', async () => { @@ -373,7 +381,7 @@ describe('List Page', () => { await waitForApolloRequestRender(); findRegistrySearch().vm.$emit('sorting:changed', { sort: 'asc' }); - await wrapper.vm.$nextTick(); + await nextTick(); expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ sort: 'UPDATED_DESC' })); }); @@ -416,7 +424,7 @@ describe('List Page', () => { await waitForApolloRequestRender(); findImageList().vm.$emit('prev-page'); - await wrapper.vm.$nextTick(); + await nextTick(); expect(resolver).toHaveBeenCalledWith( expect.objectContaining({ before: pageInfo.startCursor }), @@ -436,7 +444,7 @@ describe('List Page', () => { await waitForApolloRequestRender(); findImageList().vm.$emit('next-page'); - await wrapper.vm.$nextTick(); + await nextTick(); expect(resolver).toHaveBeenCalledWith( expect.objectContaining({ after: pageInfo.endCursor }), @@ -457,11 +465,10 @@ describe('List Page', () => { expect(findDeleteModal().exists()).toBe(true); }); - it('contains a description with the path of the item to delete', () => { + it('contains a description with the path of the item to delete', async () => { findImageList().vm.$emit('delete', { path: 'foo' }); - return wrapper.vm.$nextTick().then(() => { - expect(findDeleteModal().html()).toContain('foo'); - }); + await nextTick(); + expect(findDeleteModal().html()).toContain('foo'); }); }); @@ -497,4 +504,60 @@ describe('List Page', () => { testTrackingCall('confirm_delete'); }); }); + + describe('url query string handling', () => { + const defaultQueryParams = { + search: [1, 2], + sort: 'asc', + orderBy: 'CREATED', + }; + const queryChangePayload = 'foo'; + + it('query:updated event pushes the new query to the router', async () => { + const push = jest.fn(); + mountComponent({ mocks: { $router: { push } } }); + + await nextTick(); + + findRegistrySearch().vm.$emit('query:changed', queryChangePayload); + + expect(push).toHaveBeenCalledWith({ query: queryChangePayload }); + }); + + it('graphql API call has the variables set from the URL', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + mountComponent({ query: defaultQueryParams, resolver }); + + await nextTick(); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ + name: 1, + sort: 'CREATED_ASC', + }), + ); + }); + + it.each` + sort | orderBy | search | payload + ${'ASC'} | ${undefined} | ${undefined} | ${{ sort: 'UPDATED_ASC' }} + ${undefined} | ${'bar'} | ${undefined} | ${{ sort: 'BAR_DESC' }} + ${'ASC'} | ${'bar'} | ${undefined} | ${{ sort: 'BAR_ASC' }} + ${undefined} | ${undefined} | ${undefined} | ${{}} + ${undefined} | ${undefined} | ${['one']} | ${{ name: 'one' }} + ${undefined} | ${undefined} | ${['one', 'two']} | ${{ name: 'one' }} + ${undefined} | ${'UPDATED'} | ${['one', 'two']} | ${{ name: 'one', sort: 'UPDATED_DESC' }} + ${'ASC'} | ${'UPDATED'} | ${['one', 'two']} | ${{ name: 'one', sort: 'UPDATED_ASC' }} + `( + 'with sort equal to $sort, orderBy equal to $orderBy, search set to $search API call has the variables set as $payload', + async ({ sort, orderBy, search, payload }) => { + const resolver = jest.fn().mockResolvedValue({ sort, orderBy }); + mountComponent({ query: { sort, orderBy, search }, resolver }); + + await nextTick(); + + expect(resolver).toHaveBeenCalledWith(expect.objectContaining(payload)); + }, + ); + }); }); diff --git a/spec/frontend/registry/settings/components/settings_form_spec.js b/spec/frontend/registry/settings/components/settings_form_spec.js index 7527910ad59..ad94da6ca66 100644 --- a/spec/frontend/registry/settings/components/settings_form_spec.js +++ b/spec/frontend/registry/settings/components/settings_form_spec.js @@ -77,33 +77,47 @@ describe('Settings Form', () => { }); }; - const mountComponentWithApollo = ({ provide = defaultProvidedValues, resolver } = {}) => { + const mountComponentWithApollo = ({ + provide = defaultProvidedValues, + mutationResolver, + queryPayload = expirationPolicyPayload(), + } = {}) => { localVue.use(VueApollo); const requestHandlers = [ - [updateContainerExpirationPolicyMutation, resolver], - [expirationPolicyQuery, jest.fn().mockResolvedValue(expirationPolicyPayload())], + [updateContainerExpirationPolicyMutation, mutationResolver], + [expirationPolicyQuery, jest.fn().mockResolvedValue(queryPayload)], ]; fakeApollo = createMockApollo(requestHandlers); + // This component does not do the query directly, but we need a proper cache to update fakeApollo.defaultClient.cache.writeQuery({ query: expirationPolicyQuery, variables: { projectPath: provide.projectPath, }, - ...expirationPolicyPayload(), + ...queryPayload, }); + // we keep in sync what prop we pass to the component with the cache + const { + data: { + project: { containerExpirationPolicy: value }, + }, + } = queryPayload; + mountComponent({ provide, + props: { + ...defaultProps, + value, + }, config: { localVue, apolloProvider: fakeApollo, }, }); - - return requestHandlers.map((resolvers) => resolvers[1]); }; beforeEach(() => { @@ -253,19 +267,44 @@ describe('Settings Form', () => { expect(findSaveButton().attributes('type')).toBe('submit'); }); - it('dispatches the correct apollo mutation', async () => { - const [expirationPolicyMutationResolver] = mountComponentWithApollo({ - resolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()), + it('dispatches the correct apollo mutation', () => { + const mutationResolver = jest.fn().mockResolvedValue(expirationPolicyMutationPayload()); + mountComponentWithApollo({ + mutationResolver, }); findForm().trigger('submit'); - await expirationPolicyMutationResolver(); - expect(expirationPolicyMutationResolver).toHaveBeenCalled(); + + expect(mutationResolver).toHaveBeenCalled(); + }); + + it('saves the default values when a value is missing did not change the default options', async () => { + const mutationResolver = jest.fn().mockResolvedValue(expirationPolicyMutationPayload()); + mountComponentWithApollo({ + mutationResolver, + queryPayload: expirationPolicyPayload({ keepN: null, cadence: null, olderThan: null }), + }); + + await waitForPromises(); + + findForm().trigger('submit'); + + expect(mutationResolver).toHaveBeenCalledWith({ + input: { + cadence: 'EVERY_DAY', + enabled: true, + keepN: 'TEN_TAGS', + nameRegex: 'asdasdssssdfdf', + nameRegexKeep: 'sss', + olderThan: 'NINETY_DAYS', + projectPath: 'path', + }, + }); }); it('tracks the submit event', () => { mountComponentWithApollo({ - resolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()), + mutationResolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()), }); findForm().trigger('submit'); @@ -274,12 +313,12 @@ describe('Settings Form', () => { }); it('show a success toast when submit succeed', async () => { - const handlers = mountComponentWithApollo({ - resolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()), + mountComponentWithApollo({ + mutationResolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()), }); findForm().trigger('submit'); - await Promise.all(handlers); + await waitForPromises(); await wrapper.vm.$nextTick(); expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_SUCCESS_MESSAGE, { @@ -290,14 +329,14 @@ describe('Settings Form', () => { describe('when submit fails', () => { describe('user recoverable errors', () => { it('when there is an error is shown in a toast', async () => { - const handlers = mountComponentWithApollo({ - resolver: jest + mountComponentWithApollo({ + mutationResolver: jest .fn() .mockResolvedValue(expirationPolicyMutationPayload({ errors: ['foo'] })), }); findForm().trigger('submit'); - await Promise.all(handlers); + await waitForPromises(); await wrapper.vm.$nextTick(); expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('foo', { @@ -308,13 +347,12 @@ describe('Settings Form', () => { describe('global errors', () => { it('shows an error', async () => { - const handlers = mountComponentWithApollo({ - resolver: jest.fn().mockRejectedValue(expirationPolicyMutationPayload()), + mountComponentWithApollo({ + mutationResolver: jest.fn().mockRejectedValue(expirationPolicyMutationPayload()), }); findForm().trigger('submit'); - await Promise.all(handlers); - await wrapper.vm.$nextTick(); + await waitForPromises(); await wrapper.vm.$nextTick(); expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_ERROR_MESSAGE, { diff --git a/spec/frontend/releases/components/app_edit_new_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js index 1e55ab8f9e4..65ed6d6166f 100644 --- a/spec/frontend/releases/components/app_edit_new_spec.js +++ b/spec/frontend/releases/components/app_edit_new_spec.js @@ -50,7 +50,7 @@ describe('Release edit/new component', () => { merge( { modules: { - detail: { + editNew: { namespaced: true, actions, state, @@ -112,7 +112,7 @@ describe('Release edit/new component', () => { it('renders the description text at the top of the page', () => { expect(wrapper.find('.js-subtitle-text').text()).toBe( - 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example v1.0, v2.0-pre.', + 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example v1.0.0, v2.1.0-pre.', ); }); @@ -168,7 +168,7 @@ describe('Release edit/new component', () => { await factory({ store: { modules: { - detail: { + editNew: { getters: { isExistingRelease: () => false, }, @@ -207,7 +207,7 @@ describe('Release edit/new component', () => { await factory({ store: { modules: { - detail: { + editNew: { getters: { isValid: () => true, }, @@ -227,7 +227,7 @@ describe('Release edit/new component', () => { await factory({ store: { modules: { - detail: { + editNew: { getters: { isValid: () => false, }, diff --git a/spec/frontend/releases/components/app_index_spec.js b/spec/frontend/releases/components/app_index_spec.js index 2b5270e29d6..7955b079cbc 100644 --- a/spec/frontend/releases/components/app_index_spec.js +++ b/spec/frontend/releases/components/app_index_spec.js @@ -8,7 +8,7 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import ReleasesApp from '~/releases/components/app_index.vue'; import ReleasesPagination from '~/releases/components/releases_pagination.vue'; import createStore from '~/releases/stores'; -import createListModule from '~/releases/stores/modules/list'; +import createIndexModule from '~/releases/stores/modules/index'; import { pageInfoHeadersWithoutPagination, pageInfoHeadersWithPagination } from '../mock_data'; jest.mock('~/lib/utils/common_utils', () => ({ @@ -41,15 +41,15 @@ describe('Releases App ', () => { }; const createComponent = (stateUpdates = {}) => { - const listModule = createListModule({ + const indexModule = createIndexModule({ ...defaultInitialState, ...stateUpdates, }); - fetchReleaseSpy = jest.spyOn(listModule.actions, 'fetchReleases'); + fetchReleaseSpy = jest.spyOn(indexModule.actions, 'fetchReleases'); const store = createStore({ - modules: { list: listModule }, + modules: { index: indexModule }, featureFlags: { graphqlReleaseData: true, graphqlReleasesPage: false, diff --git a/spec/frontend/releases/components/app_show_spec.js b/spec/frontend/releases/components/app_show_spec.js index 5caea395f0a..425cb9d0059 100644 --- a/spec/frontend/releases/components/app_show_spec.js +++ b/spec/frontend/releases/components/app_show_spec.js @@ -1,63 +1,176 @@ import { shallowMount } from '@vue/test-utils'; -import Vuex from 'vuex'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { getJSONFixture } from 'helpers/fixtures'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import createFlash from '~/flash'; import ReleaseShowApp from '~/releases/components/app_show.vue'; import ReleaseBlock from '~/releases/components/release_block.vue'; import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue'; +import oneReleaseQuery from '~/releases/queries/one_release.query.graphql'; -const originalRelease = getJSONFixture('api/releases/release.json'); +jest.mock('~/flash'); + +const oneReleaseQueryResponse = getJSONFixture( + 'graphql/releases/queries/one_release.query.graphql.json', +); + +Vue.use(VueApollo); + +const EXPECTED_ERROR_MESSAGE = 'Something went wrong while getting the release details.'; +const MOCK_FULL_PATH = 'project/full/path'; +const MOCK_TAG_NAME = 'test-tag-name'; describe('Release show component', () => { let wrapper; - let release; - let actions; - beforeEach(() => { - release = convertObjectPropsToCamelCase(originalRelease); - }); - - const factory = (state) => { - actions = { - fetchRelease: jest.fn(), - }; - - const store = new Vuex.Store({ - modules: { - detail: { - namespaced: true, - actions, - state, - }, + const createComponent = ({ apolloProvider }) => { + wrapper = shallowMount(ReleaseShowApp, { + provide: { + fullPath: MOCK_FULL_PATH, + tagName: MOCK_TAG_NAME, }, + apolloProvider, }); - - wrapper = shallowMount(ReleaseShowApp, { store }); }; + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + const findLoadingSkeleton = () => wrapper.find(ReleaseSkeletonLoader); const findReleaseBlock = () => wrapper.find(ReleaseBlock); - it('calls fetchRelease when the component is created', () => { - factory({ release }); - expect(actions.fetchRelease).toHaveBeenCalledTimes(1); + const expectLoadingIndicator = () => { + it('renders a loading indicator', () => { + expect(findLoadingSkeleton().exists()).toBe(true); + }); + }; + + const expectNoLoadingIndicator = () => { + it('does not render a loading indicator', () => { + expect(findLoadingSkeleton().exists()).toBe(false); + }); + }; + + const expectNoFlash = () => { + it('does not show a flash message', () => { + expect(createFlash).not.toHaveBeenCalled(); + }); + }; + + const expectFlashWithMessage = (message) => { + it(`shows a flash message that reads "${message}"`, () => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith({ + message, + captureError: true, + error: expect.any(Error), + }); + }); + }; + + const expectReleaseBlock = () => { + it('renders a release block', () => { + expect(findReleaseBlock().exists()).toBe(true); + }); + }; + + const expectNoReleaseBlock = () => { + it('does not render a release block', () => { + expect(findReleaseBlock().exists()).toBe(false); + }); + }; + + describe('GraphQL query variables', () => { + const queryHandler = jest.fn().mockResolvedValueOnce(oneReleaseQueryResponse); + + beforeEach(() => { + const apolloProvider = createMockApollo([[oneReleaseQuery, queryHandler]]); + + createComponent({ apolloProvider }); + }); + + it('builds a GraphQL with the expected variables', () => { + expect(queryHandler).toHaveBeenCalledTimes(1); + expect(queryHandler).toHaveBeenCalledWith({ + fullPath: MOCK_FULL_PATH, + tagName: MOCK_TAG_NAME, + }); + }); }); - it('shows a loading skeleton and hides the release block while the API call is in progress', () => { - factory({ isFetchingRelease: true }); - expect(findLoadingSkeleton().exists()).toBe(true); - expect(findReleaseBlock().exists()).toBe(false); + describe('when the component is loading data', () => { + beforeEach(() => { + const apolloProvider = createMockApollo([ + [oneReleaseQuery, jest.fn().mockReturnValueOnce(new Promise(() => {}))], + ]); + + createComponent({ apolloProvider }); + }); + + expectLoadingIndicator(); + expectNoFlash(); + expectNoReleaseBlock(); }); - it('hides the loading skeleton and shows the release block when the API call finishes successfully', () => { - factory({ isFetchingRelease: false }); - expect(findLoadingSkeleton().exists()).toBe(false); - expect(findReleaseBlock().exists()).toBe(true); + describe('when the component has successfully loaded the release', () => { + beforeEach(() => { + const apolloProvider = createMockApollo([ + [oneReleaseQuery, jest.fn().mockResolvedValueOnce(oneReleaseQueryResponse)], + ]); + + createComponent({ apolloProvider }); + }); + + expectNoLoadingIndicator(); + expectNoFlash(); + expectReleaseBlock(); }); - it('hides both the loading skeleton and the release block when the API call fails', () => { - factory({ fetchError: new Error('Uh oh') }); - expect(findLoadingSkeleton().exists()).toBe(false); - expect(findReleaseBlock().exists()).toBe(false); + describe('when the request succeeded, but the returned "project" key was null', () => { + beforeEach(() => { + const apolloProvider = createMockApollo([ + [oneReleaseQuery, jest.fn().mockResolvedValueOnce({ data: { project: null } })], + ]); + + createComponent({ apolloProvider }); + }); + + expectNoLoadingIndicator(); + expectFlashWithMessage(EXPECTED_ERROR_MESSAGE); + expectNoReleaseBlock(); + }); + + describe('when the request succeeded, but the returned "project.release" key was null', () => { + beforeEach(() => { + const apolloProvider = createMockApollo([ + [ + oneReleaseQuery, + jest.fn().mockResolvedValueOnce({ data: { project: { release: null } } }), + ], + ]); + + createComponent({ apolloProvider }); + }); + + expectNoLoadingIndicator(); + expectFlashWithMessage(EXPECTED_ERROR_MESSAGE); + expectNoReleaseBlock(); + }); + + describe('when an error occurs while loading the release', () => { + beforeEach(() => { + const apolloProvider = createMockApollo([ + [oneReleaseQuery, jest.fn().mockRejectedValueOnce('An error occurred!')], + ]); + + createComponent({ apolloProvider }); + }); + + expectNoLoadingIndicator(); + expectFlashWithMessage(EXPECTED_ERROR_MESSAGE); + expectNoReleaseBlock(); }); }); diff --git a/spec/frontend/releases/components/asset_links_form_spec.js b/spec/frontend/releases/components/asset_links_form_spec.js index bbaa4e9dc94..460007e48ef 100644 --- a/spec/frontend/releases/components/asset_links_form_spec.js +++ b/spec/frontend/releases/components/asset_links_form_spec.js @@ -44,7 +44,7 @@ describe('Release edit component', () => { const store = new Vuex.Store({ modules: { - detail: { + editNew: { namespaced: true, actions, state, diff --git a/spec/frontend/releases/components/release_block_milestone_info_spec.js b/spec/frontend/releases/components/release_block_milestone_info_spec.js index 47fe10af946..a2bf45c7861 100644 --- a/spec/frontend/releases/components/release_block_milestone_info_spec.js +++ b/spec/frontend/releases/components/release_block_milestone_info_spec.js @@ -199,7 +199,7 @@ describe('Release block milestone info', () => { it('renders merge request stats', () => { expect(trimText(mergeRequestsContainer().text())).toBe( - 'Merge Requests 30 Open: 4 • Merged: 24 • Closed: 2', + 'Merge requests 30 Open: 4 • Merged: 24 • Closed: 2', ); }); }); diff --git a/spec/frontend/releases/components/releases_pagination_graphql_spec.js b/spec/frontend/releases/components/releases_pagination_graphql_spec.js index de80d82e93c..5b2dd4bc784 100644 --- a/spec/frontend/releases/components/releases_pagination_graphql_spec.js +++ b/spec/frontend/releases/components/releases_pagination_graphql_spec.js @@ -3,7 +3,7 @@ import Vuex from 'vuex'; import { historyPushState } from '~/lib/utils/common_utils'; import ReleasesPaginationGraphql from '~/releases/components/releases_pagination_graphql.vue'; import createStore from '~/releases/stores'; -import createListModule from '~/releases/stores/modules/list'; +import createIndexModule from '~/releases/stores/modules/index'; jest.mock('~/lib/utils/common_utils', () => ({ ...jest.requireActual('~/lib/utils/common_utils'), @@ -15,7 +15,7 @@ localVue.use(Vuex); describe('~/releases/components/releases_pagination_graphql.vue', () => { let wrapper; - let listModule; + let indexModule; const cursors = { startCursor: 'startCursor', @@ -25,16 +25,16 @@ describe('~/releases/components/releases_pagination_graphql.vue', () => { const projectPath = 'my/project'; const createComponent = (pageInfo) => { - listModule = createListModule({ projectPath }); + indexModule = createIndexModule({ projectPath }); - listModule.state.graphQlPageInfo = pageInfo; + indexModule.state.graphQlPageInfo = pageInfo; - listModule.actions.fetchReleases = jest.fn(); + indexModule.actions.fetchReleases = jest.fn(); wrapper = mount(ReleasesPaginationGraphql, { store: createStore({ modules: { - list: listModule, + index: indexModule, }, featureFlags: {}, }), @@ -142,7 +142,7 @@ describe('~/releases/components/releases_pagination_graphql.vue', () => { }); it('calls fetchReleases with the correct after cursor', () => { - expect(listModule.actions.fetchReleases.mock.calls).toEqual([ + expect(indexModule.actions.fetchReleases.mock.calls).toEqual([ [expect.anything(), { after: cursors.endCursor }], ]); }); @@ -160,7 +160,7 @@ describe('~/releases/components/releases_pagination_graphql.vue', () => { }); it('calls fetchReleases with the correct before cursor', () => { - expect(listModule.actions.fetchReleases.mock.calls).toEqual([ + expect(indexModule.actions.fetchReleases.mock.calls).toEqual([ [expect.anything(), { before: cursors.startCursor }], ]); }); diff --git a/spec/frontend/releases/components/releases_pagination_rest_spec.js b/spec/frontend/releases/components/releases_pagination_rest_spec.js index 6f2690f5322..7d45176967b 100644 --- a/spec/frontend/releases/components/releases_pagination_rest_spec.js +++ b/spec/frontend/releases/components/releases_pagination_rest_spec.js @@ -4,7 +4,7 @@ import Vuex from 'vuex'; import * as commonUtils from '~/lib/utils/common_utils'; import ReleasesPaginationRest from '~/releases/components/releases_pagination_rest.vue'; import createStore from '~/releases/stores'; -import createListModule from '~/releases/stores/modules/list'; +import createIndexModule from '~/releases/stores/modules/index'; commonUtils.historyPushState = jest.fn(); @@ -13,21 +13,21 @@ localVue.use(Vuex); describe('~/releases/components/releases_pagination_rest.vue', () => { let wrapper; - let listModule; + let indexModule; const projectId = 19; const createComponent = (pageInfo) => { - listModule = createListModule({ projectId }); + indexModule = createIndexModule({ projectId }); - listModule.state.restPageInfo = pageInfo; + indexModule.state.restPageInfo = pageInfo; - listModule.actions.fetchReleases = jest.fn(); + indexModule.actions.fetchReleases = jest.fn(); wrapper = mount(ReleasesPaginationRest, { store: createStore({ modules: { - list: listModule, + index: indexModule, }, featureFlags: {}, }), @@ -58,7 +58,7 @@ describe('~/releases/components/releases_pagination_rest.vue', () => { }); it('calls fetchReleases with the correct page', () => { - expect(listModule.actions.fetchReleases.mock.calls).toEqual([ + expect(indexModule.actions.fetchReleases.mock.calls).toEqual([ [expect.anything(), { page: newPage }], ]); }); diff --git a/spec/frontend/releases/components/releases_sort_spec.js b/spec/frontend/releases/components/releases_sort_spec.js index f17c6678592..b16f80b9c73 100644 --- a/spec/frontend/releases/components/releases_sort_spec.js +++ b/spec/frontend/releases/components/releases_sort_spec.js @@ -3,7 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import ReleasesSort from '~/releases/components/releases_sort.vue'; import createStore from '~/releases/stores'; -import createListModule from '~/releases/stores/modules/list'; +import createIndexModule from '~/releases/stores/modules/index'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -11,15 +11,15 @@ localVue.use(Vuex); describe('~/releases/components/releases_sort.vue', () => { let wrapper; let store; - let listModule; + let indexModule; const projectId = 8; const createComponent = () => { - listModule = createListModule({ projectId }); + indexModule = createIndexModule({ projectId }); store = createStore({ modules: { - list: listModule, + index: indexModule, }, }); @@ -52,7 +52,7 @@ describe('~/releases/components/releases_sort.vue', () => { it('on sort change set sorting in vuex and emit event', () => { findReleasesSorting().vm.$emit('sortDirectionChange'); - expect(store.dispatch).toHaveBeenCalledWith('list/setSorting', { sort: 'asc' }); + expect(store.dispatch).toHaveBeenCalledWith('index/setSorting', { sort: 'asc' }); expect(wrapper.emitted('sort:changed')).toBeTruthy(); }); @@ -60,7 +60,7 @@ describe('~/releases/components/releases_sort.vue', () => { const item = findSortingItems().at(0); const { orderBy } = wrapper.vm.sortOptions[0]; item.vm.$emit('click'); - expect(store.dispatch).toHaveBeenCalledWith('list/setSorting', { orderBy }); + expect(store.dispatch).toHaveBeenCalledWith('index/setSorting', { orderBy }); expect(wrapper.emitted('sort:changed')).toBeTruthy(); }); }); diff --git a/spec/frontend/releases/components/tag_field_exsting_spec.js b/spec/frontend/releases/components/tag_field_exsting_spec.js index cef7a0272a6..294538086b4 100644 --- a/spec/frontend/releases/components/tag_field_exsting_spec.js +++ b/spec/frontend/releases/components/tag_field_exsting_spec.js @@ -3,7 +3,7 @@ import { shallowMount, mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import TagFieldExisting from '~/releases/components/tag_field_existing.vue'; import createStore from '~/releases/stores'; -import createDetailModule from '~/releases/stores/modules/detail'; +import createEditNewModule from '~/releases/stores/modules/edit_new'; const TEST_TAG_NAME = 'test-tag-name'; @@ -27,13 +27,13 @@ describe('releases/components/tag_field_existing', () => { beforeEach(() => { store = createStore({ modules: { - detail: createDetailModule({ + editNew: createEditNewModule({ tagName: TEST_TAG_NAME, }), }, }); - store.state.detail.release = { + store.state.editNew.release = { tagName: TEST_TAG_NAME, }; }); diff --git a/spec/frontend/releases/components/tag_field_new_spec.js b/spec/frontend/releases/components/tag_field_new_spec.js index 387217c2a8e..f1608ca31b4 100644 --- a/spec/frontend/releases/components/tag_field_new_spec.js +++ b/spec/frontend/releases/components/tag_field_new_spec.js @@ -3,7 +3,7 @@ import { mount, shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import TagFieldNew from '~/releases/components/tag_field_new.vue'; import createStore from '~/releases/stores'; -import createDetailModule from '~/releases/stores/modules/detail'; +import createEditNewModule from '~/releases/stores/modules/edit_new'; const TEST_TAG_NAME = 'test-tag-name'; const TEST_PROJECT_ID = '1234'; @@ -44,15 +44,15 @@ describe('releases/components/tag_field_new', () => { beforeEach(() => { store = createStore({ modules: { - detail: createDetailModule({ + editNew: createEditNewModule({ projectId: TEST_PROJECT_ID, }), }, }); - store.state.detail.createFrom = TEST_CREATE_FROM; + store.state.editNew.createFrom = TEST_CREATE_FROM; - store.state.detail.release = { + store.state.editNew.release = { tagName: TEST_TAG_NAME, assets: { links: [], @@ -89,7 +89,7 @@ describe('releases/components/tag_field_new', () => { }); it("updates the store's release.tagName property", () => { - expect(store.state.detail.release.tagName).toBe(NONEXISTENT_TAG_NAME); + expect(store.state.editNew.release.tagName).toBe(NONEXISTENT_TAG_NAME); }); it('hides the "Create from" field', () => { @@ -107,7 +107,7 @@ describe('releases/components/tag_field_new', () => { }); it("updates the store's release.tagName property", () => { - expect(store.state.detail.release.tagName).toBe(updatedTagName); + expect(store.state.editNew.release.tagName).toBe(updatedTagName); }); it('shows the "Create from" field', () => { @@ -178,7 +178,7 @@ describe('releases/components/tag_field_new', () => { await wrapper.vm.$nextTick(); - expect(store.state.detail.createFrom).toBe(updatedCreateFrom); + expect(store.state.editNew.createFrom).toBe(updatedCreateFrom); }); }); }); diff --git a/spec/frontend/releases/components/tag_field_spec.js b/spec/frontend/releases/components/tag_field_spec.js index 2cf5944f9e6..db08f874959 100644 --- a/spec/frontend/releases/components/tag_field_spec.js +++ b/spec/frontend/releases/components/tag_field_spec.js @@ -3,7 +3,7 @@ import TagField from '~/releases/components/tag_field.vue'; import TagFieldExisting from '~/releases/components/tag_field_existing.vue'; import TagFieldNew from '~/releases/components/tag_field_new.vue'; import createStore from '~/releases/stores'; -import createDetailModule from '~/releases/stores/modules/detail'; +import createEditNewModule from '~/releases/stores/modules/edit_new'; describe('releases/components/tag_field', () => { let store; @@ -12,11 +12,11 @@ describe('releases/components/tag_field', () => { const createComponent = ({ tagName }) => { store = createStore({ modules: { - detail: createDetailModule({}), + editNew: createEditNewModule({}), }, }); - store.state.detail.tagName = tagName; + store.state.editNew.tagName = tagName; wrapper = shallowMount(TagField, { store }); }; diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js index 9c125fbb87b..b116d601ca4 100644 --- a/spec/frontend/releases/stores/modules/detail/actions_spec.js +++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js @@ -9,9 +9,9 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import httpStatus from '~/lib/utils/http_status'; import { redirectTo } from '~/lib/utils/url_utility'; import { ASSET_LINK_TYPE } from '~/releases/constants'; -import * as actions from '~/releases/stores/modules/detail/actions'; -import * as types from '~/releases/stores/modules/detail/mutation_types'; -import createState from '~/releases/stores/modules/detail/state'; +import * as actions from '~/releases/stores/modules/edit_new/actions'; +import * as types from '~/releases/stores/modules/edit_new/mutation_types'; +import createState from '~/releases/stores/modules/edit_new/state'; import { releaseToApiJson, apiJsonToRelease } from '~/releases/util'; jest.mock('~/flash'); @@ -23,7 +23,7 @@ jest.mock('~/lib/utils/url_utility', () => ({ const originalRelease = getJSONFixture('api/releases/release.json'); -describe('Release detail actions', () => { +describe('Release edit/new actions', () => { let state; let release; let mock; @@ -163,7 +163,7 @@ describe('Release detail actions', () => { return actions.fetchRelease({ commit: jest.fn(), state, rootState: state }).then(() => { expect(createFlash).toHaveBeenCalledTimes(1); expect(createFlash).toHaveBeenCalledWith( - 'Something went wrong while getting the release details', + 'Something went wrong while getting the release details.', ); }); }); diff --git a/spec/frontend/releases/stores/modules/detail/getters_spec.js b/spec/frontend/releases/stores/modules/detail/getters_spec.js index 2d9f35428f2..1449c064d77 100644 --- a/spec/frontend/releases/stores/modules/detail/getters_spec.js +++ b/spec/frontend/releases/stores/modules/detail/getters_spec.js @@ -1,6 +1,6 @@ -import * as getters from '~/releases/stores/modules/detail/getters'; +import * as getters from '~/releases/stores/modules/edit_new/getters'; -describe('Release detail getters', () => { +describe('Release edit/new getters', () => { describe('isExistingRelease', () => { it('returns true if the release is an existing release that already exists in the database', () => { const state = { tagName: 'test-tag-name' }; diff --git a/spec/frontend/releases/stores/modules/detail/mutations_spec.js b/spec/frontend/releases/stores/modules/detail/mutations_spec.js index cdf26bfa834..20ae332e500 100644 --- a/spec/frontend/releases/stores/modules/detail/mutations_spec.js +++ b/spec/frontend/releases/stores/modules/detail/mutations_spec.js @@ -1,13 +1,13 @@ import { getJSONFixture } from 'helpers/fixtures'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { ASSET_LINK_TYPE, DEFAULT_ASSET_LINK_TYPE } from '~/releases/constants'; -import * as types from '~/releases/stores/modules/detail/mutation_types'; -import mutations from '~/releases/stores/modules/detail/mutations'; -import createState from '~/releases/stores/modules/detail/state'; +import * as types from '~/releases/stores/modules/edit_new/mutation_types'; +import mutations from '~/releases/stores/modules/edit_new/mutations'; +import createState from '~/releases/stores/modules/edit_new/state'; const originalRelease = getJSONFixture('api/releases/release.json'); -describe('Release detail mutations', () => { +describe('Release edit/new mutations', () => { let state; let release; diff --git a/spec/frontend/releases/stores/modules/list/actions_spec.js b/spec/frontend/releases/stores/modules/list/actions_spec.js index 309f7387929..4dc996174bc 100644 --- a/spec/frontend/releases/stores/modules/list/actions_spec.js +++ b/spec/frontend/releases/stores/modules/list/actions_spec.js @@ -15,9 +15,9 @@ import { fetchReleasesRest, receiveReleasesError, setSorting, -} from '~/releases/stores/modules/list/actions'; -import * as types from '~/releases/stores/modules/list/mutation_types'; -import createState from '~/releases/stores/modules/list/state'; +} from '~/releases/stores/modules/index/actions'; +import * as types from '~/releases/stores/modules/index/mutation_types'; +import createState from '~/releases/stores/modules/index/state'; import { gqClient, convertAllReleasesGraphQLResponse } from '~/releases/util'; import { pageInfoHeadersWithoutPagination } from '../../../mock_data'; diff --git a/spec/frontend/releases/stores/modules/list/helpers.js b/spec/frontend/releases/stores/modules/list/helpers.js index 3913eba31b8..6669f44aa95 100644 --- a/spec/frontend/releases/stores/modules/list/helpers.js +++ b/spec/frontend/releases/stores/modules/list/helpers.js @@ -1,4 +1,4 @@ -import state from '~/releases/stores/modules/list/state'; +import state from '~/releases/stores/modules/index/state'; export const resetStore = (store) => { store.replaceState(state()); diff --git a/spec/frontend/releases/stores/modules/list/mutations_spec.js b/spec/frontend/releases/stores/modules/list/mutations_spec.js index ea6a4ada16a..8b35ba5d7ac 100644 --- a/spec/frontend/releases/stores/modules/list/mutations_spec.js +++ b/spec/frontend/releases/stores/modules/list/mutations_spec.js @@ -1,8 +1,8 @@ import { getJSONFixture } from 'helpers/fixtures'; import { parseIntPagination, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import * as types from '~/releases/stores/modules/list/mutation_types'; -import mutations from '~/releases/stores/modules/list/mutations'; -import createState from '~/releases/stores/modules/list/state'; +import * as types from '~/releases/stores/modules/index/mutation_types'; +import mutations from '~/releases/stores/modules/index/mutations'; +import createState from '~/releases/stores/modules/index/state'; import { convertAllReleasesGraphQLResponse } from '~/releases/util'; import { pageInfoHeadersWithoutPagination } from '../../../mock_data'; diff --git a/spec/frontend/reports/components/report_section_spec.js b/spec/frontend/reports/components/report_section_spec.js index c9bf3185f8f..e1b36aa1e21 100644 --- a/spec/frontend/reports/components/report_section_spec.js +++ b/spec/frontend/reports/components/report_section_spec.js @@ -1,12 +1,14 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import Vue from 'vue'; import mountComponent, { mountComponentWithSlots } from 'helpers/vue_mount_component_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import reportSection from '~/reports/components/report_section.vue'; describe('Report section', () => { let vm; let wrapper; const ReportSection = Vue.extend(reportSection); + const findCollapseButton = () => wrapper.findByTestId('report-section-expand-button'); const resolvedIssues = [ { @@ -30,12 +32,14 @@ describe('Report section', () => { }; const createComponent = (props) => { - wrapper = shallowMount(reportSection, { - propsData: { - ...defaultProps, - ...props, - }, - }); + wrapper = extendedWrapper( + mount(reportSection, { + propsData: { + ...defaultProps, + ...props, + }, + }), + ); return wrapper; }; @@ -182,7 +186,7 @@ describe('Report section', () => { expect(wrapper.emitted().toggleEvent).toBeUndefined(); - wrapper.vm.$el.querySelector('button').click(); + findCollapseButton().trigger('click'); return wrapper.vm .$nextTick() .then(() => { @@ -197,7 +201,7 @@ describe('Report section', () => { expect(wrapper.emitted().toggleEvent).toBeUndefined(); - wrapper.vm.$el.querySelector('button').click(); + findCollapseButton().trigger('click'); return wrapper.vm .$nextTick() .then(() => { diff --git a/spec/frontend/reports/grouped_test_report/components/modal_spec.js b/spec/frontend/reports/grouped_test_report/components/modal_spec.js index 303009bab3a..3de81f754fd 100644 --- a/spec/frontend/reports/grouped_test_report/components/modal_spec.js +++ b/spec/frontend/reports/grouped_test_report/components/modal_spec.js @@ -15,7 +15,10 @@ describe('Grouped Test Reports Modal', () => { // populate data modalDataStructure.execution_time.value = 0.009411; modalDataStructure.system_output.value = 'Failure/Error: is_expected.to eq(3)\n\n'; - modalDataStructure.class.value = 'link'; + modalDataStructure.filename.value = { + text: 'link', + path: '/file/path', + }; let wrapper; @@ -43,9 +46,9 @@ describe('Grouped Test Reports Modal', () => { it('renders link', () => { const link = wrapper.findComponent(GlLink); - expect(link.attributes().href).toEqual(modalDataStructure.class.value); + expect(link.attributes().href).toEqual(modalDataStructure.filename.value.path); - expect(link.text()).toEqual(modalDataStructure.class.value); + expect(link.text()).toEqual(modalDataStructure.filename.value.text); }); it('renders seconds', () => { diff --git a/spec/frontend/reports/grouped_test_report/components/test_issue_body_spec.js b/spec/frontend/reports/grouped_test_report/components/test_issue_body_spec.js index e03a52aad8d..2f6f62ca1d3 100644 --- a/spec/frontend/reports/grouped_test_report/components/test_issue_body_spec.js +++ b/spec/frontend/reports/grouped_test_report/components/test_issue_body_spec.js @@ -52,7 +52,7 @@ describe('Test issue body', () => { }); it('renders issue name', () => { - expect(findDescription().text()).toBe(failedIssue.name); + expect(findDescription().text()).toContain(failedIssue.name); }); it('renders failed status icon', () => { diff --git a/spec/frontend/reports/grouped_test_report/grouped_test_reports_app_spec.js b/spec/frontend/reports/grouped_test_report/grouped_test_reports_app_spec.js index 49332157691..55bb7dbe5c0 100644 --- a/spec/frontend/reports/grouped_test_report/grouped_test_reports_app_spec.js +++ b/spec/frontend/reports/grouped_test_report/grouped_test_reports_app_spec.js @@ -1,6 +1,6 @@ import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; -import { mockTracking } from 'helpers/tracking_helper'; +import Api from '~/api'; import GroupedTestReportsApp from '~/reports/grouped_test_report/grouped_test_reports_app.vue'; import { getStoreConfig } from '~/reports/grouped_test_report/store'; @@ -12,24 +12,31 @@ import successTestReports from '../mock_data/no_failures_report.json'; import recentFailuresTestReports from '../mock_data/recent_failures_report.json'; import resolvedFailures from '../mock_data/resolved_failures.json'; +jest.mock('~/api.js'); + const localVue = createLocalVue(); localVue.use(Vuex); describe('Grouped test reports app', () => { const endpoint = 'endpoint.json'; + const headBlobPath = '/blob/path'; const pipelinePath = '/path/to/pipeline'; let wrapper; let mockStore; - const mountComponent = ({ props = { pipelinePath } } = {}) => { + const mountComponent = ({ props = { pipelinePath }, glFeatures = {} } = {}) => { wrapper = mount(GroupedTestReportsApp, { store: mockStore, localVue, propsData: { endpoint, + headBlobPath, pipelinePath, ...props, }, + provide: { + glFeatures, + }, }); }; @@ -56,7 +63,7 @@ describe('Grouped test reports app', () => { ...getStoreConfig(), actions: { fetchReports: () => {}, - setEndpoint: () => {}, + setPaths: () => {}, }, }); mountComponent(); @@ -103,31 +110,33 @@ describe('Grouped test reports app', () => { }); describe('`Expand` button', () => { - let trackingSpy; - beforeEach(() => { setReports(newFailedTestReports); - mountComponent(); - document.body.dataset.page = 'projects:merge_requests:show'; - trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); }); - it('tracks an event on click', () => { + it('tracks usage ping metric when enabled', () => { + mountComponent({ glFeatures: { usageDataITestingSummaryWidgetTotal: true } }); findExpandButton().trigger('click'); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'expand_test_report_widget', {}); + expect(Api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); + expect(Api.trackRedisHllUserEvent).toHaveBeenCalledWith(wrapper.vm.$options.expandEvent); }); it('only tracks the first expansion', () => { - expect(trackingSpy).not.toHaveBeenCalled(); + mountComponent({ glFeatures: { usageDataITestingSummaryWidgetTotal: true } }); + const expandButton = findExpandButton(); + expandButton.trigger('click'); + expandButton.trigger('click'); + expandButton.trigger('click'); - const button = findExpandButton(); + expect(Api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); + }); - button.trigger('click'); - button.trigger('click'); - button.trigger('click'); + it('does not track usage ping metric when disabled', () => { + mountComponent({ glFeatures: { usageDataITestingSummaryWidgetTotal: false } }); + findExpandButton().trigger('click'); - expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(Api.trackRedisHllUserEvent).not.toHaveBeenCalled(); }); }); diff --git a/spec/frontend/reports/grouped_test_report/store/actions_spec.js b/spec/frontend/reports/grouped_test_report/store/actions_spec.js index 28633f7ba16..bbc3a5dbba5 100644 --- a/spec/frontend/reports/grouped_test_report/store/actions_spec.js +++ b/spec/frontend/reports/grouped_test_report/store/actions_spec.js @@ -3,7 +3,7 @@ import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; import axios from '~/lib/utils/axios_utils'; import { - setEndpoint, + setPaths, requestReports, fetchReports, stopPolling, @@ -23,13 +23,18 @@ describe('Reports Store Actions', () => { mockedState = state(); }); - describe('setEndpoint', () => { - it('should commit SET_ENDPOINT mutation', (done) => { + describe('setPaths', () => { + it('should commit SET_PATHS mutation', (done) => { testAction( - setEndpoint, - 'endpoint.json', + setPaths, + { endpoint: 'endpoint.json', headBlobPath: '/blob/path' }, mockedState, - [{ type: types.SET_ENDPOINT, payload: 'endpoint.json' }], + [ + { + type: types.SET_PATHS, + payload: { endpoint: 'endpoint.json', headBlobPath: '/blob/path' }, + }, + ], [], done, ); diff --git a/spec/frontend/reports/grouped_test_report/store/mutations_spec.js b/spec/frontend/reports/grouped_test_report/store/mutations_spec.js index 60d5016a11b..d8642a9b440 100644 --- a/spec/frontend/reports/grouped_test_report/store/mutations_spec.js +++ b/spec/frontend/reports/grouped_test_report/store/mutations_spec.js @@ -10,11 +10,15 @@ describe('Reports Store Mutations', () => { stateCopy = state(); }); - describe('SET_ENDPOINT', () => { + describe('SET_PATHS', () => { it('should set endpoint', () => { - mutations[types.SET_ENDPOINT](stateCopy, 'endpoint.json'); + mutations[types.SET_PATHS](stateCopy, { + endpoint: 'endpoint.json', + headBlobPath: '/blob/path', + }); expect(stateCopy.endpoint).toEqual('endpoint.json'); + expect(stateCopy.headBlobPath).toEqual('/blob/path'); }); }); diff --git a/spec/frontend/reports/grouped_test_report/store/utils_spec.js b/spec/frontend/reports/grouped_test_report/store/utils_spec.js index 63320744796..760afe1c11a 100644 --- a/spec/frontend/reports/grouped_test_report/store/utils_spec.js +++ b/spec/frontend/reports/grouped_test_report/store/utils_spec.js @@ -238,4 +238,18 @@ describe('Reports store utils', () => { }); }); }); + + describe('formatFilePath', () => { + it.each` + file | expected + ${'./test.js'} | ${'test.js'} + ${'/test.js'} | ${'test.js'} + ${'.//////////////test.js'} | ${'test.js'} + ${'test.js'} | ${'test.js'} + ${'mock/path./test.js'} | ${'mock/path./test.js'} + ${'./mock/path./test.js'} | ${'mock/path./test.js'} + `('should format $file to be $expected', ({ file, expected }) => { + expect(utils.formatFilePath(file)).toBe(expected); + }); + }); }); diff --git a/spec/frontend/repository/components/__snapshots__/directory_download_links_spec.js.snap b/spec/frontend/repository/components/__snapshots__/directory_download_links_spec.js.snap index 6968fb3e153..836ae5c22e6 100644 --- a/spec/frontend/repository/components/__snapshots__/directory_download_links_spec.js.snap +++ b/spec/frontend/repository/components/__snapshots__/directory_download_links_spec.js.snap @@ -16,22 +16,30 @@ exports[`Repository directory download links component renders downloads links f <div class="btn-group ml-0 w-100" > - <gl-link-stub - class="btn btn-xs btn-primary" + <gl-button-stub + buttontextclasses="" + category="primary" href="http://test.com/?path=app" + icon="" + size="small" + variant="confirm" > zip - </gl-link-stub> - <gl-link-stub - class="btn btn-xs" + </gl-button-stub> + <gl-button-stub + buttontextclasses="" + category="primary" href="http://test.com/?path=app" + icon="" + size="small" + variant="default" > tar - </gl-link-stub> + </gl-button-stub> </div> </div> </section> @@ -53,22 +61,30 @@ exports[`Repository directory download links component renders downloads links f <div class="btn-group ml-0 w-100" > - <gl-link-stub - class="btn btn-xs btn-primary" + <gl-button-stub + buttontextclasses="" + category="primary" href="http://test.com/?path=app/assets" + icon="" + size="small" + variant="confirm" > zip - </gl-link-stub> - <gl-link-stub - class="btn btn-xs" + </gl-button-stub> + <gl-button-stub + buttontextclasses="" + category="primary" href="http://test.com/?path=app/assets" + icon="" + size="small" + variant="default" > tar - </gl-link-stub> + </gl-button-stub> </div> </div> </section> diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js new file mode 100644 index 00000000000..b662a1d20a9 --- /dev/null +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -0,0 +1,86 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import BlobContent from '~/blob/components/blob_content.vue'; +import BlobHeader from '~/blob/components/blob_header.vue'; +import BlobContentViewer from '~/repository/components/blob_content_viewer.vue'; + +let wrapper; +const mockData = { + name: 'some_file.js', + size: 123, + rawBlob: 'raw content', + type: 'text', + fileType: 'text', + tooLarge: false, + path: 'some_file.js', + editBlobPath: 'some_file.js/edit', + ideEditPath: 'some_file.js/ide/edit', + storedExternally: false, + rawPath: 'some_file.js', + externalStorageUrl: 'some_file.js', + replacePath: 'some_file.js/replace', + deletePath: 'some_file.js/delete', + canLock: true, + isLocked: false, + lockLink: 'some_file.js/lock', + canModifyBlob: true, + forkPath: 'some_file.js/fork', + simpleViewer: {}, + richViewer: {}, +}; + +function factory(path, loading = false) { + wrapper = shallowMount(BlobContentViewer, { + propsData: { + path, + }, + mocks: { + $apollo: { + queries: { + blobInfo: { + loading, + }, + }, + }, + }, + }); + + wrapper.setData({ blobInfo: mockData }); +} + +describe('Blob content viewer component', () => { + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findBlobHeader = () => wrapper.find(BlobHeader); + const findBlobContent = () => wrapper.find(BlobContent); + + afterEach(() => { + wrapper.destroy(); + }); + + beforeEach(() => { + factory('some_file.js'); + }); + + it('renders a GlLoadingIcon component', () => { + factory('some_file.js', true); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('renders a BlobHeader component', () => { + expect(findBlobHeader().exists()).toBe(true); + }); + + it('renders a BlobContent component', () => { + expect(findBlobContent().exists()).toBe(true); + + expect(findBlobContent().props('loading')).toEqual(false); + expect(findBlobContent().props('content')).toEqual('raw content'); + expect(findBlobContent().props('isRawContent')).toBe(true); + expect(findBlobContent().props('activeViewer')).toEqual({ + fileType: 'text', + tooLarge: false, + type: 'text', + }); + }); +}); diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js index 2ac2069a177..93bfd3d9d32 100644 --- a/spec/frontend/repository/components/breadcrumbs_spec.js +++ b/spec/frontend/repository/components/breadcrumbs_spec.js @@ -1,24 +1,36 @@ import { GlDropdown } from '@gitlab/ui'; import { shallowMount, RouterLinkStub } from '@vue/test-utils'; import Breadcrumbs from '~/repository/components/breadcrumbs.vue'; - -let vm; - -function factory(currentPath, extraProps = {}) { - vm = shallowMount(Breadcrumbs, { - propsData: { - currentPath, - ...extraProps, - }, - stubs: { - RouterLink: RouterLinkStub, - }, - }); -} +import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; describe('Repository breadcrumbs component', () => { + let wrapper; + + const factory = (currentPath, extraProps = {}) => { + const $apollo = { + queries: { + userPermissions: { + loading: true, + }, + }, + }; + + wrapper = shallowMount(Breadcrumbs, { + propsData: { + currentPath, + ...extraProps, + }, + stubs: { + RouterLink: RouterLinkStub, + }, + mocks: { $apollo }, + }); + }; + + const findUploadBlobModal = () => wrapper.find(UploadBlobModal); + afterEach(() => { - vm.destroy(); + wrapper.destroy(); }); it.each` @@ -30,13 +42,13 @@ describe('Repository breadcrumbs component', () => { `('renders $linkCount links for path $path', ({ path, linkCount }) => { factory(path); - expect(vm.findAll(RouterLinkStub).length).toEqual(linkCount); + expect(wrapper.findAll(RouterLinkStub).length).toEqual(linkCount); }); it('escapes hash in directory path', () => { factory('app/assets/javascripts#'); - expect(vm.findAll(RouterLinkStub).at(3).props('to')).toEqual( + expect(wrapper.findAll(RouterLinkStub).at(3).props('to')).toEqual( '/-/tree/app/assets/javascripts%23', ); }); @@ -44,26 +56,44 @@ describe('Repository breadcrumbs component', () => { it('renders last link as active', () => { factory('app/assets'); - expect(vm.findAll(RouterLinkStub).at(2).attributes('aria-current')).toEqual('page'); + expect(wrapper.findAll(RouterLinkStub).at(2).attributes('aria-current')).toEqual('page'); }); - it('does not render add to tree dropdown when permissions are false', () => { + it('does not render add to tree dropdown when permissions are false', async () => { factory('/', { canCollaborate: false }); - vm.setData({ userPermissions: { forkProject: false, createMergeRequestIn: false } }); + wrapper.setData({ userPermissions: { forkProject: false, createMergeRequestIn: false } }); - return vm.vm.$nextTick(() => { - expect(vm.find(GlDropdown).exists()).toBe(false); - }); + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlDropdown).exists()).toBe(false); }); - it('renders add to tree dropdown when permissions are true', () => { + it('renders add to tree dropdown when permissions are true', async () => { factory('/', { canCollaborate: true }); - vm.setData({ userPermissions: { forkProject: true, createMergeRequestIn: true } }); + wrapper.setData({ userPermissions: { forkProject: true, createMergeRequestIn: true } }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlDropdown).exists()).toBe(true); + }); + + describe('renders the upload blob modal', () => { + beforeEach(() => { + factory('/', { canEditTree: true }); + }); + + it('does not render the modal while loading', () => { + expect(findUploadBlobModal().exists()).toBe(false); + }); + + it('renders the modal once loaded', async () => { + wrapper.setData({ $apollo: { queries: { userPermissions: { loading: false } } } }); + + await wrapper.vm.$nextTick(); - return vm.vm.$nextTick(() => { - expect(vm.find(GlDropdown).exists()).toBe(true); + expect(findUploadBlobModal().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js index 69cb69de5df..3ebffbedcdb 100644 --- a/spec/frontend/repository/components/table/row_spec.js +++ b/spec/frontend/repository/components/table/row_spec.js @@ -19,6 +19,9 @@ function factory(propsData = {}) { projectPath: 'gitlab-org/gitlab-ce', url: `https://test.com`, }, + provide: { + glFeatures: { refactorBlobViewer: true }, + }, mocks: { $router, }, @@ -81,7 +84,7 @@ describe('Repository table row component', () => { it.each` type | component | componentName ${'tree'} | ${RouterLinkStub} | ${'RouterLink'} - ${'file'} | ${'a'} | ${'hyperlink'} + ${'blob'} | ${RouterLinkStub} | ${'RouterLink'} ${'commit'} | ${'a'} | ${'hyperlink'} `('renders a $componentName for type $type', ({ type, component }) => { factory({ diff --git a/spec/frontend/repository/pages/blob_spec.js b/spec/frontend/repository/pages/blob_spec.js new file mode 100644 index 00000000000..3e7ead4ad00 --- /dev/null +++ b/spec/frontend/repository/pages/blob_spec.js @@ -0,0 +1,25 @@ +import { shallowMount } from '@vue/test-utils'; +import BlobContentViewer from '~/repository/components/blob_content_viewer.vue'; +import BlobPage from '~/repository/pages/blob.vue'; + +jest.mock('~/repository/utils/dom'); + +describe('Repository blob page component', () => { + let wrapper; + + const findBlobContentViewer = () => wrapper.find(BlobContentViewer); + const path = 'file.js'; + + beforeEach(() => { + wrapper = shallowMount(BlobPage, { propsData: { path } }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('has a Blob Content Viewer component', () => { + expect(findBlobContentViewer().exists()).toBe(true); + expect(findBlobContentViewer().props('path')).toBe(path); + }); +}); diff --git a/spec/frontend/repository/router_spec.js b/spec/frontend/repository/router_spec.js index 3c7dda05ca3..3354b2315fc 100644 --- a/spec/frontend/repository/router_spec.js +++ b/spec/frontend/repository/router_spec.js @@ -1,3 +1,4 @@ +import BlobPage from '~/repository/pages/blob.vue'; import IndexPage from '~/repository/pages/index.vue'; import TreePage from '~/repository/pages/tree.vue'; import createRouter from '~/repository/router'; @@ -11,6 +12,7 @@ describe('Repository router spec', () => { ${'/-/tree/master'} | ${'master'} | ${TreePage} | ${'TreePage'} ${'/-/tree/master/app/assets'} | ${'master'} | ${TreePage} | ${'TreePage'} ${'/-/tree/123/app/assets'} | ${'master'} | ${null} | ${'null'} + ${'/-/blob/master/file.md'} | ${'master'} | ${BlobPage} | ${'BlobPage'} `('sets component as $componentName for path "$path"', ({ path, component, branch }) => { const router = createRouter('', branch); diff --git a/spec/frontend/runner/runner_detail/runner_detail_app_spec.js b/spec/frontend/runner/runner_detail/runner_detail_app_spec.js new file mode 100644 index 00000000000..5caa37c8cb3 --- /dev/null +++ b/spec/frontend/runner/runner_detail/runner_detail_app_spec.js @@ -0,0 +1,29 @@ +import { shallowMount } from '@vue/test-utils'; +import RunnerDetailsApp from '~/runner/runner_details/runner_details_app.vue'; + +const mockRunnerId = '55'; + +describe('RunnerDetailsApp', () => { + let wrapper; + + const createComponent = (props) => { + wrapper = shallowMount(RunnerDetailsApp, { + propsData: { + runnerId: mockRunnerId, + ...props, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays the runner id', () => { + expect(wrapper.text()).toContain('Runner #55'); + }); +}); diff --git a/spec/frontend/security_configuration/configuration_table_spec.js b/spec/frontend/security_configuration/configuration_table_spec.js index b8a574dc4e0..a1789052c92 100644 --- a/spec/frontend/security_configuration/configuration_table_spec.js +++ b/spec/frontend/security_configuration/configuration_table_spec.js @@ -30,7 +30,7 @@ describe('Configuration Table Component', () => { expect(wrapper.text()).toContain(scanner.name); expect(wrapper.text()).toContain(scanner.description); if (scanner.type === REPORT_TYPE_SAST) { - expect(wrapper.findByTestId(scanner.type).text()).toBe('Configure via Merge Request'); + expect(wrapper.findByTestId(scanner.type).text()).toBe('Configure via merge request'); } else if (scanner.type !== REPORT_TYPE_SECRET_DETECTION) { expect(wrapper.findByTestId(scanner.type).text()).toMatchInterpolatedText(UPGRADE_CTA); } diff --git a/spec/frontend/security_configuration/manage_sast_spec.js b/spec/frontend/security_configuration/manage_sast_spec.js index 7c76f19ddb4..15a57210246 100644 --- a/spec/frontend/security_configuration/manage_sast_spec.js +++ b/spec/frontend/security_configuration/manage_sast_spec.js @@ -79,7 +79,7 @@ describe('Manage Sast Component', () => { it('should render Button with correct text', () => { createComponent(); - expect(findButton().text()).toContain('Configure via Merge Request'); + expect(findButton().text()).toContain('Configure via merge request'); }); describe('given a successful response', () => { diff --git a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap index 4b827301943..33df3a66fcd 100644 --- a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap +++ b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap @@ -10,8 +10,8 @@ exports[`EmptyStateComponent should render content 1`] = ` <h1 class=\\"h4\\">Getting started with serverless</h1> <p>In order to start using functions as a service, you must first install Knative on your Kubernetes cluster. <gl-link-stub href=\\"/help\\">More information</gl-link-stub> </p> - <div> - <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" href=\\"/clusters\\">Install Knative</gl-button-stub> + <div class=\\"gl-display-flex gl-flex-wrap gl-justify-content-center\\"> + <gl-button-stub category=\\"primary\\" variant=\\"confirm\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" href=\\"/clusters\\" class=\\"gl-mb-3 gl-mx-2\\">Install Knative</gl-button-stub> <!----> </div> </div> diff --git a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js index 21b9721438d..403f9509f84 100644 --- a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js +++ b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js @@ -44,6 +44,7 @@ describe('SetStatusModalWrapper', () => { const findNoEmojiPlaceholder = () => wrapper.find('.js-no-emoji-placeholder'); const findToggleEmojiButton = () => wrapper.find('.js-toggle-emoji-menu'); const findAvailabilityCheckbox = () => wrapper.find(GlFormCheckbox); + const findClearStatusAtMessage = () => wrapper.find('[data-testid="clear-status-at-message"]'); const initModal = ({ mockOnUpdateSuccess = true, mockOnUpdateFailure = true } = {}) => { const modal = findModal(); @@ -57,18 +58,18 @@ describe('SetStatusModalWrapper', () => { return wrapper.vm.$nextTick(); }; - beforeEach(async () => { - mockEmoji = await initEmojiMock(); - wrapper = createComponent(); - return initModal(); - }); - afterEach(() => { wrapper.destroy(); mockEmoji.restore(); }); describe('with minimum props', () => { + beforeEach(async () => { + mockEmoji = await initEmojiMock(); + wrapper = createComponent(); + return initModal(); + }); + it('sets the hidden status emoji field', () => { const field = findFormField('emoji'); expect(field.exists()).toBe(true); @@ -96,6 +97,14 @@ describe('SetStatusModalWrapper', () => { findToggleEmojiButton().trigger('click'); expect(wrapper.vm.showEmojiMenu).toHaveBeenCalled(); }); + + it('displays the clear status at dropdown', () => { + expect(wrapper.find('[data-testid="clear-status-at-dropdown"]').exists()).toBe(true); + }); + + it('does not display the clear status at message', () => { + expect(findClearStatusAtMessage().exists()).toBe(false); + }); }); describe('with no currentMessage set', () => { @@ -146,9 +155,28 @@ describe('SetStatusModalWrapper', () => { }); }); + describe('with currentClearStatusAfter set', () => { + beforeEach(async () => { + mockEmoji = await initEmojiMock(); + wrapper = createComponent({ currentClearStatusAfter: '2021-01-01 00:00:00 UTC' }); + return initModal(); + }); + + it('displays the clear status at message', () => { + const clearStatusAtMessage = findClearStatusAtMessage(); + + expect(clearStatusAtMessage.exists()).toBe(true); + expect(clearStatusAtMessage.text()).toBe('Your status resets on 2021-01-01 00:00:00 UTC.'); + }); + }); + describe('update status', () => { describe('succeeds', () => { - beforeEach(() => { + beforeEach(async () => { + mockEmoji = await initEmojiMock(); + wrapper = createComponent(); + await initModal(); + jest.spyOn(UserApi, 'updateUserStatus').mockResolvedValue(); }); @@ -167,18 +195,26 @@ describe('SetStatusModalWrapper', () => { // set the availability status findAvailabilityCheckbox().vm.$emit('input', true); + // set the currentClearStatusAfter to 30 minutes + wrapper.find('[data-testid="thirtyMinutes"]').vm.$emit('click'); + findModal().vm.$emit('ok'); await wrapper.vm.$nextTick(); - const commonParams = { emoji: defaultEmoji, message: defaultMessage }; + const commonParams = { + emoji: defaultEmoji, + message: defaultMessage, + }; expect(UserApi.updateUserStatus).toHaveBeenCalledTimes(2); expect(UserApi.updateUserStatus).toHaveBeenNthCalledWith(1, { availability: AVAILABILITY_STATUS.NOT_SET, + clearStatusAfter: null, ...commonParams, }); expect(UserApi.updateUserStatus).toHaveBeenNthCalledWith(2, { availability: AVAILABILITY_STATUS.BUSY, + clearStatusAfter: '30_minutes', ...commonParams, }); }); @@ -208,7 +244,11 @@ describe('SetStatusModalWrapper', () => { }); describe('with errors', () => { - beforeEach(() => { + beforeEach(async () => { + mockEmoji = await initEmojiMock(); + wrapper = createComponent(); + await initModal(); + jest.spyOn(UserApi, 'updateUserStatus').mockRejectedValue(); }); diff --git a/spec/frontend/sidebar/assignees_realtime_spec.js b/spec/frontend/sidebar/assignees_realtime_spec.js index 0fab6a29f71..f0a6fa40d67 100644 --- a/spec/frontend/sidebar/assignees_realtime_spec.js +++ b/spec/frontend/sidebar/assignees_realtime_spec.js @@ -1,7 +1,7 @@ import ActionCable from '@rails/actioncable'; import { shallowMount } from '@vue/test-utils'; -import query from '~/issuable_sidebar/queries/issue_sidebar.query.graphql'; import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; +import { assigneesQueries } from '~/sidebar/constants'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import Mock from './mock_data'; @@ -18,18 +18,19 @@ describe('Assignees Realtime', () => { let wrapper; let mediator; - const createComponent = () => { + const createComponent = (issuableType = 'issue') => { wrapper = shallowMount(AssigneesRealtime, { propsData: { issuableIid: '1', mediator, projectPath: 'path/to/project', + issuableType, }, mocks: { $apollo: { - query, + query: assigneesQueries[issuableType].query, queries: { - project: { + workspace: { refetch: jest.fn(), }, }, @@ -51,8 +52,8 @@ describe('Assignees Realtime', () => { describe('when handleFetchResult is called from smart query', () => { it('sets assignees to the store', () => { const data = { - project: { - issue: { + workspace: { + issuable: { assignees: { nodes: [{ id: 'gid://gitlab/Environments/123', avatarUrl: 'url' }], }, @@ -95,7 +96,7 @@ describe('Assignees Realtime', () => { wrapper.vm.received({ event: 'updated' }); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.$apollo.queries.project.refetch).toHaveBeenCalledTimes(1); + expect(wrapper.vm.$apollo.queries.workspace.refetch).toHaveBeenCalledTimes(1); }); }); }); diff --git a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js new file mode 100644 index 00000000000..824f6d49c65 --- /dev/null +++ b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js @@ -0,0 +1,558 @@ +import { GlSearchBoxByType, GlDropdown } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { cloneDeep } from 'lodash'; +import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphql'; +import { IssuableType } from '~/issue_show/constants'; +import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; +import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; +import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; +import SidebarInviteMembers from '~/sidebar/components/assignees/sidebar_invite_members.vue'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants'; +import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue'; +import getIssueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; +import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql'; +import { + issuableQueryResponse, + searchQueryResponse, + updateIssueAssigneesMutationResponse, +} from '../../mock_data'; + +jest.mock('~/flash'); + +const updateIssueAssigneesMutationSuccess = jest + .fn() + .mockResolvedValue(updateIssueAssigneesMutationResponse); +const mockError = jest.fn().mockRejectedValue('Error!'); + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +const initialAssignees = [ + { + id: 'some-user', + avatarUrl: 'some-user-avatar', + name: 'test', + username: 'test', + webUrl: '/test', + }, +]; + +describe('Sidebar assignees widget', () => { + let wrapper; + let fakeApollo; + + const findAssignees = () => wrapper.findComponent(IssuableAssignees); + const findRealtimeAssignees = () => wrapper.findComponent(SidebarAssigneesRealtime); + const findEditableItem = () => wrapper.findComponent(SidebarEditableItem); + const findDropdown = () => wrapper.findComponent(MultiSelectDropdown); + const findInviteMembersLink = () => wrapper.findComponent(SidebarInviteMembers); + const findSearchField = () => wrapper.findComponent(GlSearchBoxByType); + + const findParticipantsLoading = () => wrapper.find('[data-testid="loading-participants"]'); + const findSelectedParticipants = () => wrapper.findAll('[data-testid="selected-participant"]'); + const findUnselectedParticipants = () => + wrapper.findAll('[data-testid="unselected-participant"]'); + const findCurrentUser = () => wrapper.findAll('[data-testid="current-user"]'); + const findUnassignLink = () => wrapper.find('[data-testid="unassign"]'); + const findEmptySearchResults = () => wrapper.find('[data-testid="empty-results"]'); + + const expandDropdown = () => wrapper.vm.$refs.toggle.expand(); + + const createComponent = ({ + search = '', + issuableQueryHandler = jest.fn().mockResolvedValue(issuableQueryResponse), + searchQueryHandler = jest.fn().mockResolvedValue(searchQueryResponse), + updateIssueAssigneesMutationHandler = updateIssueAssigneesMutationSuccess, + props = {}, + provide = {}, + } = {}) => { + fakeApollo = createMockApollo([ + [getIssueParticipantsQuery, issuableQueryHandler], + [searchUsersQuery, searchQueryHandler], + [updateIssueAssigneesMutation, updateIssueAssigneesMutationHandler], + ]); + wrapper = shallowMount(SidebarAssigneesWidget, { + localVue, + apolloProvider: fakeApollo, + propsData: { + iid: '1', + fullPath: '/mygroup/myProject', + ...props, + }, + data() { + return { + search, + selected: [], + }; + }, + provide: { + canUpdate: true, + rootPath: '/', + ...provide, + }, + stubs: { + SidebarEditableItem, + MultiSelectDropdown, + GlSearchBoxByType, + GlDropdown, + }, + }); + }; + + beforeEach(() => { + gon.current_username = 'root'; + gon.current_user_fullname = 'Administrator'; + gon.current_user_avatar_url = '/root'; + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + fakeApollo = null; + delete gon.current_username; + }); + + describe('with passed initial assignees', () => { + it('passes `initialLoading` as false to editable item', () => { + createComponent({ + props: { + initialAssignees, + }, + }); + + expect(findEditableItem().props('initialLoading')).toBe(false); + }); + + it('renders an initial assignees list with initialAssignees prop', () => { + createComponent({ + props: { + initialAssignees, + }, + }); + + expect(findAssignees().props('users')).toEqual(initialAssignees); + }); + + it('renders a collapsible item title calculated with initial assignees length', () => { + createComponent({ + props: { + initialAssignees, + }, + }); + + expect(findEditableItem().props('title')).toBe('Assignee'); + }); + + describe('when expanded', () => { + it('renders a loading spinner if participants are loading', () => { + createComponent({ + props: { + initialAssignees, + }, + }); + expandDropdown(); + + expect(findParticipantsLoading().exists()).toBe(true); + }); + }); + }); + + describe('without passed initial assignees', () => { + it('passes `initialLoading` as true to editable item', () => { + createComponent(); + + expect(findEditableItem().props('initialLoading')).toBe(true); + }); + + it('renders assignees list from API response when resolved', async () => { + createComponent(); + await waitForPromises(); + + expect(findAssignees().props('users')).toEqual( + issuableQueryResponse.data.workspace.issuable.assignees.nodes, + ); + }); + + it('renders an error when issuable query is rejected', async () => { + createComponent({ + issuableQueryHandler: mockError, + }); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'An error occurred while fetching participants.', + }); + }); + + it('assigns current user when clicking `Assign self`', async () => { + createComponent(); + + await waitForPromises(); + + findAssignees().vm.$emit('assign-self'); + + expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ + assigneeUsernames: 'root', + fullPath: '/mygroup/myProject', + iid: '1', + }); + + await waitForPromises(); + + expect( + findAssignees() + .props('users') + .some((user) => user.username === 'root'), + ).toBe(true); + }); + + it('emits an event with assignees list on successful mutation', async () => { + createComponent(); + + await waitForPromises(); + + findAssignees().vm.$emit('assign-self'); + + expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ + assigneeUsernames: 'root', + fullPath: '/mygroup/myProject', + iid: '1', + }); + + await waitForPromises(); + + expect(wrapper.emitted('assignees-updated')).toEqual([ + [ + [ + { + __typename: 'User', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: 'gid://gitlab/User/1', + name: 'Administrator', + username: 'root', + webUrl: '/root', + status: null, + }, + ], + ], + ]); + }); + + it('renders current user if they are not in participants or assignees', async () => { + gon.current_username = 'random'; + gon.current_user_fullname = 'Mr Random'; + gon.current_user_avatar_url = '/random'; + + createComponent(); + await waitForPromises(); + expandDropdown(); + + expect(findCurrentUser().exists()).toBe(true); + }); + + describe('when expanded', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + expandDropdown(); + }); + + it('collapses the widget on multiselect dropdown toggle event', async () => { + findDropdown().vm.$emit('toggle'); + await nextTick(); + expect(findDropdown().isVisible()).toBe(false); + }); + + it('renders participants list with correct amount of selected and unselected', async () => { + expect(findSelectedParticipants()).toHaveLength(1); + expect(findUnselectedParticipants()).toHaveLength(2); + }); + + it('does not render current user if they are in participants', () => { + expect(findCurrentUser().exists()).toBe(false); + }); + + it('unassigns all participants when clicking on `Unassign`', () => { + findUnassignLink().vm.$emit('click'); + findEditableItem().vm.$emit('close'); + + expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ + assigneeUsernames: [], + fullPath: '/mygroup/myProject', + iid: '1', + }); + }); + }); + + describe('when multiselect is disabled', () => { + beforeEach(async () => { + createComponent({ props: { multipleAssignees: false } }); + await waitForPromises(); + expandDropdown(); + }); + + it('adds a single assignee when clicking on unselected user', async () => { + findUnselectedParticipants().at(0).vm.$emit('click'); + + expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ + assigneeUsernames: ['root'], + fullPath: '/mygroup/myProject', + iid: '1', + }); + }); + + it('removes an assignee when clicking on selected user', () => { + findSelectedParticipants().at(0).vm.$emit('click', new Event('click')); + + expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ + assigneeUsernames: [], + fullPath: '/mygroup/myProject', + iid: '1', + }); + }); + }); + + describe('when multiselect is enabled', () => { + beforeEach(async () => { + createComponent({ props: { multipleAssignees: true } }); + await waitForPromises(); + expandDropdown(); + }); + + it('adds a few assignees after clicking on unselected users and closing a dropdown', () => { + findUnselectedParticipants().at(0).vm.$emit('click'); + findUnselectedParticipants().at(1).vm.$emit('click'); + findEditableItem().vm.$emit('close'); + + expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ + assigneeUsernames: ['francina.skiles', 'root', 'johndoe'], + fullPath: '/mygroup/myProject', + iid: '1', + }); + }); + + it('removes an assignee when clicking on selected user and then closing dropdown', () => { + findSelectedParticipants().at(0).vm.$emit('click', new Event('click')); + + findEditableItem().vm.$emit('close'); + + expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ + assigneeUsernames: [], + fullPath: '/mygroup/myProject', + iid: '1', + }); + }); + + it('does not call a mutation when clicking on participants until dropdown is closed', () => { + findUnselectedParticipants().at(0).vm.$emit('click'); + findSelectedParticipants().at(0).vm.$emit('click', new Event('click')); + + expect(updateIssueAssigneesMutationSuccess).not.toHaveBeenCalled(); + }); + }); + + it('shows an error if update assignees mutation is rejected', async () => { + createComponent({ updateIssueAssigneesMutationHandler: mockError }); + await waitForPromises(); + expandDropdown(); + + findUnassignLink().vm.$emit('click'); + findEditableItem().vm.$emit('close'); + + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'An error occurred while updating assignees.', + }); + }); + + describe('when searching', () => { + it('does not show loading spinner when debounce timer is still running', async () => { + createComponent({ search: 'roo' }); + await waitForPromises(); + expandDropdown(); + + expect(findParticipantsLoading().exists()).toBe(false); + }); + + it('shows loading spinner when searching for users', async () => { + createComponent({ search: 'roo' }); + await waitForPromises(); + expandDropdown(); + jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY); + await nextTick(); + + expect(findParticipantsLoading().exists()).toBe(true); + }); + + it('renders a list of found users and external participants matching search term', async () => { + const responseCopy = cloneDeep(issuableQueryResponse); + responseCopy.data.workspace.issuable.participants.nodes.push({ + id: 'gid://gitlab/User/5', + avatarUrl: '/someavatar', + name: 'Roodie', + username: 'roodie', + webUrl: '/roodie', + status: null, + }); + + const issuableQueryHandler = jest.fn().mockResolvedValue(responseCopy); + + createComponent({ issuableQueryHandler }); + await waitForPromises(); + expandDropdown(); + + findSearchField().vm.$emit('input', 'roo'); + await nextTick(); + + jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY); + await nextTick(); + await waitForPromises(); + + expect(findUnselectedParticipants()).toHaveLength(3); + }); + + it('renders a list of found users only if no external participants match search term', async () => { + createComponent({ search: 'roo' }); + await waitForPromises(); + expandDropdown(); + jest.advanceTimersByTime(250); + await nextTick(); + await waitForPromises(); + + expect(findUnselectedParticipants()).toHaveLength(2); + }); + + it('shows a message about no matches if search returned an empty list', async () => { + const responseCopy = cloneDeep(searchQueryResponse); + responseCopy.data.workspace.users.nodes = []; + + createComponent({ + search: 'roo', + searchQueryHandler: jest.fn().mockResolvedValue(responseCopy), + }); + await waitForPromises(); + expandDropdown(); + jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY); + await nextTick(); + await waitForPromises(); + + expect(findUnselectedParticipants()).toHaveLength(0); + expect(findEmptySearchResults().exists()).toBe(true); + }); + + it('shows an error if search query was rejected', async () => { + createComponent({ search: 'roo', searchQueryHandler: mockError }); + await waitForPromises(); + expandDropdown(); + jest.advanceTimersByTime(250); + await nextTick(); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'An error occurred while searching users.', + }); + }); + }); + }); + + describe('when user is not signed in', () => { + beforeEach(() => { + gon.current_username = undefined; + createComponent(); + }); + + it('does not show current user in the dropdown', () => { + expandDropdown(); + expect(findCurrentUser().exists()).toBe(false); + }); + + it('passes signedIn prop as false to IssuableAssignees', () => { + expect(findAssignees().props('signedIn')).toBe(false); + }); + }); + + it('when realtime feature flag is disabled', async () => { + createComponent(); + await waitForPromises(); + expect(findRealtimeAssignees().exists()).toBe(false); + }); + + it('when realtime feature flag is enabled', async () => { + createComponent({ + provide: { + glFeatures: { + realTimeIssueSidebar: true, + }, + }, + }); + await waitForPromises(); + expect(findRealtimeAssignees().exists()).toBe(true); + }); + + describe('when making changes to participants list', () => { + beforeEach(async () => { + createComponent(); + }); + + it('passes falsy `isDirty` prop to editable item if no changes to selected users were made', () => { + expandDropdown(); + expect(findEditableItem().props('isDirty')).toBe(false); + }); + + it('passes truthy `isDirty` prop if selected users list was changed', async () => { + expandDropdown(); + expect(findEditableItem().props('isDirty')).toBe(false); + findUnselectedParticipants().at(0).vm.$emit('click'); + await nextTick(); + expect(findEditableItem().props('isDirty')).toBe(true); + }); + + it('passes falsy `isDirty` prop after dropdown is closed', async () => { + expandDropdown(); + findUnselectedParticipants().at(0).vm.$emit('click'); + findEditableItem().vm.$emit('close'); + await waitForPromises(); + expect(findEditableItem().props('isDirty')).toBe(false); + }); + }); + + it('does not render invite members link on non-issue sidebar', async () => { + createComponent({ props: { issuableType: IssuableType.MergeRequest } }); + await waitForPromises(); + expect(findInviteMembersLink().exists()).toBe(false); + }); + + it('does not render invite members link if `directlyInviteMembers` and `indirectlyInviteMembers` were not passed', async () => { + createComponent(); + await waitForPromises(); + expect(findInviteMembersLink().exists()).toBe(false); + }); + + it('renders invite members link if `directlyInviteMembers` is true', async () => { + createComponent({ + provide: { + directlyInviteMembers: true, + }, + }); + await waitForPromises(); + expect(findInviteMembersLink().exists()).toBe(true); + }); + + it('renders invite members link if `indirectlyInviteMembers` is true', async () => { + createComponent({ + provide: { + indirectlyInviteMembers: true, + }, + }); + await waitForPromises(); + expect(findInviteMembersLink().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/sidebar/components/assignees/sidebar_editable_item_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_editable_item_spec.js index 4ee12838491..84b192aaf41 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_editable_item_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_editable_item_spec.js @@ -5,7 +5,7 @@ import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue' describe('boards sidebar remove issue', () => { let wrapper; - const findLoader = () => wrapper.find(GlLoadingIcon); + const findLoader = () => wrapper.findComponent(GlLoadingIcon); const findEditButton = () => wrapper.find('[data-testid="edit-button"]'); const findTitle = () => wrapper.find('[data-testid="title"]'); const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]'); @@ -117,4 +117,35 @@ describe('boards sidebar remove issue', () => { expect(wrapper.emitted().close).toBeUndefined(); }); + + it('renders `Edit` test when passed `isDirty` prop is false', () => { + createComponent({ props: { isDirty: false }, canUpdate: true }); + + expect(findEditButton().text()).toBe('Edit'); + }); + + it('renders `Apply` test when passed `isDirty` prop is true', () => { + createComponent({ props: { isDirty: true }, canUpdate: true }); + + expect(findEditButton().text()).toBe('Apply'); + }); + + describe('when initial loading is true', () => { + beforeEach(() => { + createComponent({ props: { initialLoading: true } }); + }); + + it('renders loading icon', () => { + expect(findLoader().exists()).toBe(true); + }); + + it('does not render edit button', () => { + expect(findEditButton().exists()).toBe(false); + }); + + it('does not render collapsed and expanded content', () => { + expect(findCollapsed().exists()).toBe(false); + expect(findExpanded().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js new file mode 100644 index 00000000000..06f7da3d1ab --- /dev/null +++ b/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js @@ -0,0 +1,59 @@ +import { shallowMount } from '@vue/test-utils'; +import InviteMemberModal from '~/invite_member/components/invite_member_modal.vue'; +import InviteMemberTrigger from '~/invite_member/components/invite_member_trigger.vue'; +import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; +import SidebarInviteMembers from '~/sidebar/components/assignees/sidebar_invite_members.vue'; + +const testProjectMembersPath = 'test-path'; + +describe('Sidebar invite members component', () => { + let wrapper; + + const findDirectInviteLink = () => wrapper.findComponent(InviteMembersTrigger); + const findIndirectInviteLink = () => wrapper.findComponent(InviteMemberTrigger); + const findInviteModal = () => wrapper.findComponent(InviteMemberModal); + + const createComponent = ({ directlyInviteMembers = false } = {}) => { + wrapper = shallowMount(SidebarInviteMembers, { + provide: { + directlyInviteMembers, + projectMembersPath: testProjectMembersPath, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when directly inviting members', () => { + beforeEach(() => { + createComponent({ directlyInviteMembers: true }); + }); + + it('renders a direct link to project members path', () => { + expect(findDirectInviteLink().exists()).toBe(true); + }); + + it('does not render invite members trigger and modal components', () => { + expect(findIndirectInviteLink().exists()).toBe(false); + expect(findInviteModal().exists()).toBe(false); + }); + }); + + describe('when indirectly inviting members', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not render a direct link to project members path', () => { + expect(findDirectInviteLink().exists()).toBe(false); + }); + + it('does not render invite members trigger and modal components', () => { + expect(findIndirectInviteLink().exists()).toBe(true); + expect(findInviteModal().exists()).toBe(true); + expect(findInviteModal().props('membersPath')).toBe(testProjectMembersPath); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js new file mode 100644 index 00000000000..88a5f4ea8b7 --- /dev/null +++ b/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js @@ -0,0 +1,43 @@ +import { GlAvatarLabeled } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; + +const user = { + name: 'John Doe', + username: 'johndoe', + webUrl: '/link', + avatarUrl: '/avatar', +}; + +describe('Sidebar participant component', () => { + let wrapper; + + const findAvatar = () => wrapper.findComponent(GlAvatarLabeled); + + const createComponent = (status = null) => { + wrapper = shallowMount(SidebarParticipant, { + propsData: { + user: { + ...user, + status, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('when user is not busy', () => { + createComponent(); + + expect(findAvatar().props('label')).toBe(user.name); + }); + + it('when user is busy', () => { + createComponent({ availability: 'BUSY' }); + + expect(findAvatar().props('label')).toBe(`${user.name} (Busy)`); + }); +}); diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js index d5e6310ed38..28a19fb9df6 100644 --- a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js +++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js @@ -20,11 +20,9 @@ describe('Sidebar Confidentiality Form', () => { mutate = jest.fn().mockResolvedValue('Success'), } = {}) => { wrapper = shallowMount(SidebarConfidentialityForm, { - provide: { + propsData: { fullPath: 'group/project', iid: '1', - }, - propsData: { confidential: false, issuableType: 'issue', ...props, diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js index 20a5be9b518..707215d0739 100644 --- a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js +++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js @@ -35,11 +35,11 @@ describe('Sidebar Confidentiality Widget', () => { localVue, apolloProvider: fakeApollo, provide: { - fullPath: 'group/project', - iid: '1', canUpdate: true, }, propsData: { + fullPath: 'group/project', + iid: '1', issuableType: 'issue', }, stubs: { diff --git a/spec/frontend/sidebar/components/copy_email_to_clipboard_spec.js b/spec/frontend/sidebar/components/copy_email_to_clipboard_spec.js index 704847f65bf..699b2bbd0b1 100644 --- a/spec/frontend/sidebar/components/copy_email_to_clipboard_spec.js +++ b/spec/frontend/sidebar/components/copy_email_to_clipboard_spec.js @@ -1,22 +1,17 @@ -import { getByText } from '@testing-library/dom'; -import { mount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import CopyEmailToClipboard from '~/sidebar/components/copy_email_to_clipboard.vue'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue'; describe('CopyEmailToClipboard component', () => { - const sampleEmail = 'sample+email@test.com'; + const mockIssueEmailAddress = 'sample+email@test.com'; - const wrapper = mount(CopyEmailToClipboard, { + const wrapper = shallowMount(CopyEmailToClipboard, { propsData: { - copyText: sampleEmail, + issueEmailAddress: mockIssueEmailAddress, }, }); - it('renders the Issue email text with the forwardable email', () => { - expect(getByText(wrapper.element, `Issue email: ${sampleEmail}`)).not.toBeNull(); - }); - - it('finds ClipboardButton with the correct props', () => { - expect(wrapper.find(ClipboardButton).props('text')).toBe(sampleEmail); + it('sets CopyableField `value` prop to issueEmailAddress', () => { + expect(wrapper.find(CopyableField).props('value')).toBe(mockIssueEmailAddress); }); }); diff --git a/spec/frontend/sidebar/components/due_date/sidebar_due_date_widget_spec.js b/spec/frontend/sidebar/components/due_date/sidebar_due_date_widget_spec.js new file mode 100644 index 00000000000..f58ceb0f1be --- /dev/null +++ b/spec/frontend/sidebar/components/due_date/sidebar_due_date_widget_spec.js @@ -0,0 +1,106 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import SidebarDueDateWidget from '~/sidebar/components/due_date/sidebar_due_date_widget.vue'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql'; +import { issueDueDateResponse } from '../../mock_data'; + +jest.mock('~/flash'); + +Vue.use(VueApollo); + +describe('Sidebar Due date Widget', () => { + let wrapper; + let fakeApollo; + const date = '2021-04-15'; + + const findEditableItem = () => wrapper.findComponent(SidebarEditableItem); + const findFormattedDueDate = () => wrapper.find("[data-testid='sidebar-duedate-value']"); + + const createComponent = ({ + dueDateQueryHandler = jest.fn().mockResolvedValue(issueDueDateResponse()), + } = {}) => { + fakeApollo = createMockApollo([[issueDueDateQuery, dueDateQueryHandler]]); + + wrapper = shallowMount(SidebarDueDateWidget, { + apolloProvider: fakeApollo, + provide: { + fullPath: 'group/project', + iid: '1', + canUpdate: true, + }, + propsData: { + issuableType: 'issue', + }, + stubs: { + SidebarEditableItem, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + it('passes a `loading` prop as true to editable item when query is loading', () => { + createComponent(); + + expect(findEditableItem().props('loading')).toBe(true); + }); + + describe('when issue has no due date', () => { + beforeEach(async () => { + createComponent({ + dueDateQueryHandler: jest.fn().mockResolvedValue(issueDueDateResponse(null)), + }); + await waitForPromises(); + }); + + it('passes a `loading` prop as false to editable item', () => { + expect(findEditableItem().props('loading')).toBe(false); + }); + + it('dueDate is null by default', () => { + expect(findFormattedDueDate().text()).toBe('None'); + }); + + it('emits `dueDateUpdated` event with a `null` payload', () => { + expect(wrapper.emitted('dueDateUpdated')).toEqual([[null]]); + }); + }); + + describe('when issue has due date', () => { + beforeEach(async () => { + createComponent({ + dueDateQueryHandler: jest.fn().mockResolvedValue(issueDueDateResponse(date)), + }); + await waitForPromises(); + }); + + it('passes a `loading` prop as false to editable item', () => { + expect(findEditableItem().props('loading')).toBe(false); + }); + + it('has dueDate', () => { + expect(findFormattedDueDate().text()).toBe('Apr 15, 2021'); + }); + + it('emits `dueDateUpdated` event with the date payload', () => { + expect(wrapper.emitted('dueDateUpdated')).toEqual([[date]]); + }); + }); + + it('displays a flash message when query is rejected', async () => { + createComponent({ + dueDateQueryHandler: jest.fn().mockRejectedValue('Houston, we have a problem'), + }); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js b/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js index 1dbb7702a15..cc428693930 100644 --- a/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js +++ b/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js @@ -1,4 +1,3 @@ -import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; @@ -8,18 +7,21 @@ import { IssuableType } from '~/issue_show/constants'; import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue'; import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql'; import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue'; import { issueReferenceResponse } from '../../mock_data'; describe('Sidebar Reference Widget', () => { let wrapper; let fakeApollo; - const referenceText = 'reference'; + + const mockReferenceValue = 'reference-1234'; + + const findCopyableField = () => wrapper.findComponent(CopyableField); const createComponent = ({ - issuableType, + issuableType = IssuableType.Issue, referenceQuery = issueReferenceQuery, - referenceQueryHandler = jest.fn().mockResolvedValue(issueReferenceResponse(referenceText)), + referenceQueryHandler = jest.fn().mockResolvedValue(issueReferenceResponse(mockReferenceValue)), } = {}) => { Vue.use(VueApollo); @@ -39,14 +41,20 @@ describe('Sidebar Reference Widget', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; + }); + + describe('when reference is loading', () => { + it('sets CopyableField `is-loading` prop to `true`', () => { + createComponent({ referenceQueryHandler: jest.fn().mockReturnValue(new Promise(() => {})) }); + expect(findCopyableField().props('isLoading')).toBe(true); + }); }); describe.each([ [IssuableType.Issue, issueReferenceQuery], [IssuableType.MergeRequest, mergeRequestReferenceQuery], ])('when issuableType is %s', (issuableType, referenceQuery) => { - it('displays the reference text', async () => { + it('sets CopyableField `value` prop to reference value', async () => { createComponent({ issuableType, referenceQuery, @@ -54,40 +62,32 @@ describe('Sidebar Reference Widget', () => { await waitForPromises(); - expect(wrapper.text()).toContain(referenceText); + expect(findCopyableField().props('value')).toBe(mockReferenceValue); }); - it('displays loading icon while fetching and hides clipboard icon', async () => { - createComponent({ - issuableType, - referenceQuery, - }); + describe('when error occurs', () => { + it('calls createFlash with correct parameters', async () => { + const mockError = new Error('mayday'); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); - expect(wrapper.find(ClipboardButton).exists()).toBe(false); - }); + createComponent({ + issuableType, + referenceQuery, + referenceQueryHandler: jest.fn().mockRejectedValue(mockError), + }); - it('calls createFlash with correct parameters', async () => { - const mockError = new Error('mayday'); + await waitForPromises(); - createComponent({ - issuableType, - referenceQuery, - referenceQueryHandler: jest.fn().mockRejectedValue(mockError), + const [ + [ + { + message, + error: { networkError }, + }, + ], + ] = wrapper.emitted('fetch-error'); + expect(message).toBe('An error occurred while fetching reference'); + expect(networkError).toEqual(mockError); }); - - await waitForPromises(); - - const [ - [ - { - message, - error: { networkError }, - }, - ], - ] = wrapper.emitted('fetch-error'); - expect(message).toBe('An error occurred while fetching reference'); - expect(networkError).toEqual(mockError); }); }); }); diff --git a/spec/frontend/sidebar/issuable_assignees_spec.js b/spec/frontend/sidebar/issuable_assignees_spec.js index af4dc315aad..3563d478f3f 100644 --- a/spec/frontend/sidebar/issuable_assignees_spec.js +++ b/spec/frontend/sidebar/issuable_assignees_spec.js @@ -5,12 +5,15 @@ import UncollapsedAssigneeList from '~/sidebar/components/assignees/uncollapsed_ describe('IssuableAssignees', () => { let wrapper; - const createComponent = (props = { users: [] }) => { + const createComponent = (props = {}) => { wrapper = shallowMount(IssuableAssignees, { provide: { rootPath: '', }, - propsData: { ...props }, + propsData: { + users: [], + ...props, + }, }); }; const findUncollapsedAssigneeList = () => wrapper.find(UncollapsedAssigneeList); @@ -22,12 +25,14 @@ describe('IssuableAssignees', () => { }); describe('when no assignees are present', () => { - beforeEach(() => { - createComponent(); + it('renders "None - assign yourself" when user is logged in', () => { + createComponent({ signedIn: true }); + expect(findEmptyAssignee().text()).toBe('None - assign yourself'); }); - it('renders "None - assign yourself"', () => { - expect(findEmptyAssignee().text()).toBe('None - assign yourself'); + it('renders "None" when user is not logged in', () => { + createComponent(); + expect(findEmptyAssignee().text()).toBe('None'); }); }); @@ -41,7 +46,7 @@ describe('IssuableAssignees', () => { describe('when clicking "assign yourself"', () => { it('emits "assign-self"', () => { - createComponent(); + createComponent({ signedIn: true }); wrapper.find('[data-testid="assign-yourself"]').vm.$emit('click'); expect(wrapper.emitted('assign-self')).toHaveLength(1); }); diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js index e751f1239c8..2a4858a6320 100644 --- a/spec/frontend/sidebar/mock_data.js +++ b/spec/frontend/sidebar/mock_data.js @@ -233,6 +233,19 @@ export const issueConfidentialityResponse = (confidential = false) => ({ }, }); +export const issueDueDateResponse = (dueDate = null) => ({ + data: { + workspace: { + __typename: 'Project', + issuable: { + __typename: 'Issue', + id: 'gid://gitlab/Issue/4', + dueDate, + }, + }, + }, +}); + export const issueReferenceResponse = (reference) => ({ data: { workspace: { @@ -245,4 +258,147 @@ export const issueReferenceResponse = (reference) => ({ }, }, }); + +export const issuableQueryResponse = { + data: { + workspace: { + __typename: 'Project', + issuable: { + __typename: 'Issue', + id: 'gid://gitlab/Issue/1', + iid: '1', + participants: { + nodes: [ + { + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + name: 'Administrator', + username: 'root', + webUrl: '/root', + status: null, + }, + { + id: 'gid://gitlab/User/2', + avatarUrl: + 'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon', + name: 'Jacki Kub', + username: 'francina.skiles', + webUrl: '/franc', + status: { + availability: 'BUSY', + }, + }, + { + id: 'gid://gitlab/User/3', + avatarUrl: '/avatar', + name: 'John Doe', + username: 'johndoe', + webUrl: '/john', + status: null, + }, + ], + }, + assignees: { + nodes: [ + { + id: 'gid://gitlab/User/2', + avatarUrl: + 'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon', + name: 'Jacki Kub', + username: 'francina.skiles', + webUrl: '/franc', + status: null, + }, + ], + }, + }, + }, + }, +}; + +export const searchQueryResponse = { + data: { + workspace: { + __typename: 'Project', + users: { + nodes: [ + { + user: { + id: '1', + avatarUrl: '/avatar', + name: 'root', + username: 'root', + webUrl: 'root', + status: null, + }, + }, + { + user: { + id: '2', + avatarUrl: '/avatar2', + name: 'rookie', + username: 'rookie', + webUrl: 'rookie', + status: null, + }, + }, + ], + }, + }, + }, +}; + +export const updateIssueAssigneesMutationResponse = { + data: { + issuableSetAssignees: { + issuable: { + id: 'gid://gitlab/Issue/1', + iid: '1', + assignees: { + nodes: [ + { + __typename: 'User', + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + name: 'Administrator', + username: 'root', + webUrl: '/root', + status: null, + }, + ], + __typename: 'UserConnection', + }, + participants: { + nodes: [ + { + __typename: 'User', + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + name: 'Administrator', + username: 'root', + webUrl: '/root', + status: null, + }, + { + __typename: 'User', + id: 'gid://gitlab/User/2', + avatarUrl: + 'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon', + name: 'Jacki Kub', + username: 'francina.skiles', + webUrl: '/franc', + status: null, + }, + ], + __typename: 'UserConnection', + }, + __typename: 'Issue', + }, + }, + }, +}; + export default mockData; 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 cef5f8cc528..22e206bb483 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 @@ -25,9 +25,11 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = <div class="js-vue-markdown-field md-area position-relative gfm-form js-expanded" + data-uploads-path="" > <markdown-header-stub linecontent="" + suggestionstartindex="0" /> <div diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js index 2b6d3ca8c2a..efdb52cfcd9 100644 --- a/spec/frontend/snippets/components/edit_spec.js +++ b/spec/frontend/snippets/components/edit_spec.js @@ -5,10 +5,9 @@ import { nextTick } from 'vue'; import VueApollo, { ApolloMutation } from 'vue-apollo'; import { useFakeDate } from 'helpers/fake_date'; import createMockApollo from 'helpers/mock_apollo_helper'; -import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; import GetSnippetQuery from 'shared_queries/snippet/snippet.query.graphql'; -import CaptchaModal from '~/captcha/captcha_modal.vue'; +import UnsolvedCaptchaError from '~/captcha/unsolved_captcha_error'; import { deprecatedCreateFlash as Flash } from '~/flash'; import * as urlUtils from '~/lib/utils/url_utility'; import SnippetEditApp from '~/snippets/components/edit.vue'; @@ -30,9 +29,8 @@ jest.mock('~/flash'); const TEST_UPLOADED_FILES = ['foo/bar.txt', 'alpha/beta.js']; const TEST_API_ERROR = new Error('TEST_API_ERROR'); +const TEST_CAPTCHA_ERROR = new UnsolvedCaptchaError(); const TEST_MUTATION_ERROR = 'Test mutation error'; -const TEST_CAPTCHA_RESPONSE = 'i-got-a-captcha'; -const TEST_CAPTCHA_SITE_KEY = 'abc123'; const TEST_ACTIONS = { NO_CONTENT: merge({}, testEntries.created.diff, { content: '' }), NO_PATH: merge({}, testEntries.created.diff, { filePath: '' }), @@ -59,9 +57,6 @@ const createMutationResponse = (key, obj = {}) => ({ __typename: 'Snippet', webUrl: TEST_WEB_URL, }, - spamLogId: null, - needsCaptchaResponse: false, - captchaSiteKey: null, }, obj, ), @@ -71,13 +66,6 @@ const createMutationResponse = (key, obj = {}) => ({ const createMutationResponseWithErrors = (key) => createMutationResponse(key, { errors: [TEST_MUTATION_ERROR] }); -const createMutationResponseWithRecaptcha = (key) => - createMutationResponse(key, { - errors: ['ignored captcha error message'], - needsCaptchaResponse: true, - captchaSiteKey: TEST_CAPTCHA_SITE_KEY, - }); - const getApiData = ({ id, title = '', @@ -126,7 +114,6 @@ describe('Snippet Edit app', () => { }); const findBlobActions = () => wrapper.find(SnippetBlobActionsEdit); - const findCaptchaModal = () => wrapper.find(CaptchaModal); const findSubmitButton = () => wrapper.find('[data-testid="snippet-submit-btn"]'); const findCancelButton = () => wrapper.find('[data-testid="snippet-cancel-btn"]'); const hasDisabledSubmit = () => Boolean(findSubmitButton().attributes('disabled')); @@ -159,7 +146,6 @@ describe('Snippet Edit app', () => { stubs: { ApolloMutation, FormFooterActions, - CaptchaModal: stubComponent(CaptchaModal), }, provide: { selectedLevel, @@ -209,7 +195,6 @@ describe('Snippet Edit app', () => { }); it('should render components', () => { - expect(wrapper.find(CaptchaModal).exists()).toBe(true); expect(wrapper.find(TitleField).exists()).toBe(true); expect(wrapper.find(SnippetDescriptionEdit).exists()).toBe(true); expect(wrapper.find(SnippetVisibilityEdit).exists()).toBe(true); @@ -338,10 +323,10 @@ describe('Snippet Edit app', () => { }, ); - describe('with apollo network error', () => { + describe.each([TEST_API_ERROR, TEST_CAPTCHA_ERROR])('with apollo network error', (error) => { beforeEach(async () => { jest.spyOn(console, 'error').mockImplementation(); - mutateSpy.mockRejectedValue(TEST_API_ERROR); + mutateSpy.mockRejectedValue(error); await createComponentAndSubmit(); }); @@ -353,7 +338,7 @@ describe('Snippet Edit app', () => { it('should flash', () => { // Apollo automatically wraps the resolver's error in a NetworkError expect(Flash).toHaveBeenCalledWith( - `Can't update snippet: Network error: ${TEST_API_ERROR.message}`, + `Can't update snippet: Network error: ${error.message}`, ); }); @@ -363,54 +348,10 @@ describe('Snippet Edit app', () => { // eslint-disable-next-line no-console expect(console.error).toHaveBeenCalledWith( '[gitlab] unexpected error while updating snippet', - expect.objectContaining({ message: `Network error: ${TEST_API_ERROR.message}` }), + expect.objectContaining({ message: `Network error: ${error.message}` }), ); }); }); - - describe('when needsCaptchaResponse is true', () => { - let modal; - - beforeEach(async () => { - mutateSpy - .mockResolvedValueOnce(createMutationResponseWithRecaptcha('updateSnippet')) - .mockResolvedValueOnce(createMutationResponseWithErrors('updateSnippet')); - - await createComponentAndSubmit(); - - modal = findCaptchaModal(); - - mutateSpy.mockClear(); - }); - - it('should display captcha modal', () => { - expect(urlUtils.redirectTo).not.toHaveBeenCalled(); - expect(modal.props()).toEqual({ - needsCaptchaResponse: true, - captchaSiteKey: TEST_CAPTCHA_SITE_KEY, - }); - }); - - describe.each` - response | expectedCalls - ${null} | ${[]} - ${TEST_CAPTCHA_RESPONSE} | ${[['updateSnippet', { input: { ...getApiData(createSnippet()), captchaResponse: TEST_CAPTCHA_RESPONSE } }]]} - `('when captcha response is $response', ({ response, expectedCalls }) => { - beforeEach(async () => { - modal.vm.$emit('receivedCaptchaResponse', response); - - await nextTick(); - }); - - it('sets needsCaptchaResponse to false', () => { - expect(modal.props('needsCaptchaResponse')).toEqual(false); - }); - - it(`expected to call times = ${expectedCalls.length}`, () => { - expect(mutateSpy.mock.calls).toEqual(expectedCalls); - }); - }); - }); }); }); diff --git a/spec/frontend/tags/components/sort_dropdown_spec.js b/spec/frontend/tags/components/sort_dropdown_spec.js new file mode 100644 index 00000000000..b0fd98ec68e --- /dev/null +++ b/spec/frontend/tags/components/sort_dropdown_spec.js @@ -0,0 +1,81 @@ +import { GlDropdownItem, GlSearchBoxByClick } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import * as urlUtils from '~/lib/utils/url_utility'; +import SortDropdown from '~/tags/components/sort_dropdown.vue'; + +describe('Tags sort dropdown', () => { + let wrapper; + + const createWrapper = (props = {}) => { + return extendedWrapper( + mount(SortDropdown, { + provide: { + filterTagsPath: '/root/ci-cd-project-demo/-/tags', + sortOptions: { + name_asc: 'Name', + updated_asc: 'Oldest updated', + updated_desc: 'Last updated', + }, + ...props, + }, + }), + ); + }; + + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByClick); + const findTagsDropdown = () => wrapper.findByTestId('tags-dropdown'); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('default state', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('should have a search box with a placeholder', () => { + const searchBox = findSearchBox(); + + expect(searchBox.exists()).toBe(true); + expect(searchBox.find('input').attributes('placeholder')).toBe('Filter by tag name'); + }); + + it('should have a sort order dropdown', () => { + const branchesDropdown = findTagsDropdown(); + + expect(branchesDropdown.exists()).toBe(true); + }); + }); + + describe('when submitting a search term', () => { + beforeEach(() => { + urlUtils.visitUrl = jest.fn(); + + wrapper = createWrapper(); + }); + + it('should call visitUrl', () => { + const searchBox = findSearchBox(); + + searchBox.vm.$emit('submit'); + + expect(urlUtils.visitUrl).toHaveBeenCalledWith( + '/root/ci-cd-project-demo/-/tags?sort=updated_desc', + ); + }); + + it('should send a sort parameter', () => { + const sortDropdownItems = findTagsDropdown().findAllComponents(GlDropdownItem).at(0); + + sortDropdownItems.vm.$emit('click'); + + expect(urlUtils.visitUrl).toHaveBeenCalledWith( + '/root/ci-cd-project-demo/-/tags?sort=name_asc', + ); + }); + }); +}); diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js index 6a22de3be5c..2c7bcaa98b0 100644 --- a/spec/frontend/tracking_spec.js +++ b/spec/frontend/tracking_spec.js @@ -176,25 +176,29 @@ describe('Tracking', () => { }); }); - describe('tracking interface events', () => { + describe.each` + term + ${'event'} + ${'action'} + `('tracking interface events with data-track-$term', ({ term }) => { let eventSpy; beforeEach(() => { eventSpy = jest.spyOn(Tracking, 'event'); Tracking.bindDocument('_category_'); // only happens once setHTMLFixture(` - <input data-track-event="click_input1" data-track-label="_label_" value="_value_"/> - <input data-track-event="click_input2" data-track-value="_value_override_" value="_value_"/> - <input type="checkbox" data-track-event="toggle_checkbox" value="_value_" checked/> - <input class="dropdown" data-track-event="toggle_dropdown"/> - <div data-track-event="nested_event"><span class="nested"></span></div> - <input data-track-eventbogus="click_bogusinput" data-track-label="_label_" value="_value_"/> - <input data-track-event="click_input3" data-track-experiment="example" value="_value_"/> + <input data-track-${term}="click_input1" data-track-label="_label_" value="_value_"/> + <input data-track-${term}="click_input2" data-track-value="_value_override_" value="_value_"/> + <input type="checkbox" data-track-${term}="toggle_checkbox" value="_value_" checked/> + <input class="dropdown" data-track-${term}="toggle_dropdown"/> + <div data-track-${term}="nested_event"><span class="nested"></span></div> + <input data-track-bogus="click_bogusinput" data-track-label="_label_" value="_value_"/> + <input data-track-${term}="click_input3" data-track-experiment="example" value="_value_"/> `); }); - it('binds to clicks on elements matching [data-track-event]', () => { - document.querySelector('[data-track-event="click_input1"]').click(); + it(`binds to clicks on elements matching [data-track-${term}]`, () => { + document.querySelector(`[data-track-${term}="click_input1"]`).click(); expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input1', { label: '_label_', @@ -202,14 +206,14 @@ describe('Tracking', () => { }); }); - it('does not bind to clicks on elements without [data-track-event]', () => { - document.querySelector('[data-track-eventbogus="click_bogusinput"]').click(); + it(`does not bind to clicks on elements without [data-track-${term}]`, () => { + document.querySelector('[data-track-bogus="click_bogusinput"]').click(); expect(eventSpy).not.toHaveBeenCalled(); }); it('allows value override with the data-track-value attribute', () => { - document.querySelector('[data-track-event="click_input2"]').click(); + document.querySelector(`[data-track-${term}="click_input2"]`).click(); expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input2', { value: '_value_override_', @@ -217,7 +221,7 @@ describe('Tracking', () => { }); it('handles checkbox values correctly', () => { - const checkbox = document.querySelector('[data-track-event="toggle_checkbox"]'); + const checkbox = document.querySelector(`[data-track-${term}="toggle_checkbox"]`); checkbox.click(); // unchecking @@ -233,7 +237,7 @@ describe('Tracking', () => { }); it('handles bootstrap dropdowns', () => { - const dropdown = document.querySelector('[data-track-event="toggle_dropdown"]'); + const dropdown = document.querySelector(`[data-track-${term}="toggle_dropdown"]`); dropdown.dispatchEvent(new Event('show.bs.dropdown', { bubbles: true })); @@ -250,7 +254,7 @@ describe('Tracking', () => { expect(eventSpy).toHaveBeenCalledWith('_category_', 'nested_event', {}); }); - it('brings in experiment data if linked to an experiment', () => { + it('includes experiment data if linked to an experiment', () => { const mockExperimentData = { variant: 'candidate', experiment: 'repo_integrations_link', @@ -258,7 +262,7 @@ describe('Tracking', () => { }; getExperimentData.mockReturnValue(mockExperimentData); - document.querySelector('[data-track-event="click_input3"]').click(); + document.querySelector(`[data-track-${term}="click_input3"]`).click(); expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input3', { value: '_value_', @@ -267,22 +271,26 @@ describe('Tracking', () => { }); }); - describe('tracking page loaded events', () => { + describe.each` + term + ${'event'} + ${'action'} + `('tracking page loaded events with -$term', ({ term }) => { let eventSpy; beforeEach(() => { eventSpy = jest.spyOn(Tracking, 'event'); setHTMLFixture(` - <input data-track-event="render" data-track-label="label1" value="_value_" data-track-property="_property_"/> - <span data-track-event="render" data-track-label="label2" data-track-value="_value_"> + <input data-track-${term}="render" data-track-label="label1" value="_value_" data-track-property="_property_"/> + <span data-track-${term}="render" data-track-label="label2" data-track-value="_value_"> Something </span> - <input data-track-event="_render_bogus_" data-track-label="label3" value="_value_" data-track-property="_property_"/> + <input data-track-${term}="_render_bogus_" data-track-label="label3" value="_value_" data-track-property="_property_"/> `); Tracking.trackLoadEvents('_category_'); // only happens once }); - it('sends tracking events when [data-track-event="render"] is on an element', () => { + it(`sends tracking events when [data-track-${term}="render"] is on an element`, () => { expect(eventSpy.mock.calls).toEqual([ [ '_category_', @@ -318,6 +326,30 @@ describe('Tracking', () => { mixin.computed.tracking = { foo: 'baz', baz: 'bar' }; expect(mixin.computed.trackingOptions()).toEqual({ foo: 'baz', baz: 'bar' }); }); + + it('includes experiment data if linked to an experiment', () => { + const mockExperimentData = { + variant: 'candidate', + experiment: 'darkMode', + }; + getExperimentData.mockReturnValue(mockExperimentData); + + const mixin = Tracking.mixin({ foo: 'bar', experiment: 'darkMode' }); + expect(mixin.computed.trackingOptions()).toEqual({ + foo: 'bar', + context: { + schema: TRACKING_CONTEXT_SCHEMA, + data: mockExperimentData, + }, + }); + }); + + it('does not include experiment data if experiment data does not exist', () => { + const mixin = Tracking.mixin({ foo: 'bar', experiment: 'lightMode' }); + expect(mixin.computed.trackingOptions()).toEqual({ + foo: 'bar', + }); + }); }); describe('trackingCategory', () => { diff --git a/spec/frontend/users_select/index_spec.js b/spec/frontend/users_select/index_spec.js new file mode 100644 index 00000000000..5b07087b76c --- /dev/null +++ b/spec/frontend/users_select/index_spec.js @@ -0,0 +1,223 @@ +import { waitFor } from '@testing-library/dom'; +import MockAdapter from 'axios-mock-adapter'; +import { cloneDeep } from 'lodash'; +import { getJSONFixture } from 'helpers/fixtures'; +import axios from '~/lib/utils/axios_utils'; +import UsersSelect from '~/users_select'; + +// TODO: generate this from a fixture that guarantees the same output in CE and EE [(see issue)][1]. +// Hardcoding this HTML temproarily fixes a FOSS ~"master::broken" [(see issue)][2]. +// [1]: https://gitlab.com/gitlab-org/gitlab/-/issues/327809 +// [2]: https://gitlab.com/gitlab-org/gitlab/-/issues/327805 +const getUserSearchHTML = () => ` +<div class="js-sidebar-assignee-data selectbox hide-collapsed"> +<input type="hidden" name="merge_request[assignee_ids][]" value="0"> +<div class="dropdown js-sidebar-assignee-dropdown"> +<button class="dropdown-menu-toggle js-user-search js-author-search js-multiselect js-save-user-data js-invite-members-track" type="button" data-first-user="frontend-fixtures" data-current-user="true" data-iid="1" data-issuable-type="merge_request" data-project-id="1" data-author-id="1" data-field-name="merge_request[assignee_ids][]" data-issue-update="http://test.host/frontend-fixtures/merge-requests-project/-/merge_requests/1.json" data-ability-name="merge_request" data-null-user="true" data-display="static" data-multi-select="true" data-dropdown-title="Select assignee(s)" data-dropdown-header="Assignee(s)" data-track-event="show_invite_members" data-toggle="dropdown"><span class="dropdown-toggle-text ">Select assignee(s)</span><svg class="s16 dropdown-menu-toggle-icon gl-top-3" data-testid="chevron-down-icon"><use xlink:href="http://test.host/assets/icons-16c30bec0d8a57f0a33e6f6215c6aff7a6ec5e4a7e6b7de733a6b648541a336a.svg#chevron-down"></use></svg></button><div class="dropdown-menu dropdown-select dropdown-menu-user dropdown-menu-selectable dropdown-menu-author dropdown-extended-height"> +<div class="dropdown-title gl-display-flex"> +<span class="gl-ml-auto">Assign to</span><button class="dropdown-title-button dropdown-menu-close gl-ml-auto" aria-label="Close" type="button"><svg class="s16 dropdown-menu-close-icon" data-testid="close-icon"><use xlink:href="http://test.host/assets/icons-16c30bec0d8a57f0a33e6f6215c6aff7a6ec5e4a7e6b7de733a6b648541a336a.svg#close"></use></svg></button> +</div> +<div class="dropdown-input"> +<input type="search" id="" data-qa-selector="dropdown_input_field" class="dropdown-input-field" placeholder="Search users" autocomplete="off"><svg class="s16 dropdown-input-search" data-testid="search-icon"><use xlink:href="http://test.host/assets/icons-16c30bec0d8a57f0a33e6f6215c6aff7a6ec5e4a7e6b7de733a6b648541a336a.svg#search"></use></svg><svg class="s16 dropdown-input-clear js-dropdown-input-clear" data-testid="close-icon"><use xlink:href="http://test.host/assets/icons-16c30bec0d8a57f0a33e6f6215c6aff7a6ec5e4a7e6b7de733a6b648541a336a.svg#close"></use></svg> +</div> +<div data-qa-selector="dropdown_list_content" class="dropdown-content "></div> +<div class="dropdown-footer"> +<ul class="dropdown-footer-list"> +<li> +<div class="js-invite-members-trigger" data-display-text="Invite Members" data-event="click_invite_members" data-label="edit_assignee" data-trigger-element="anchor"></div> +</li> +</ul> +</div> +<div class="dropdown-loading"><div class="gl-spinner-container"><span class="gl-spinner gl-spinner-orange gl-spinner-md gl-mt-7" aria-label="Loading"></span></div></div> +</div> +</div> +</div> +`; + +const USER_SEARCH_HTML = getUserSearchHTML(); +const AUTOCOMPLETE_USERS = getJSONFixture('autocomplete/users.json'); + +describe('~/users_select/index', () => { + let subject; + let mock; + + const createSubject = (currentUser = null) => { + if (subject) { + throw new Error('test subject is already created'); + } + + subject = new UsersSelect(currentUser); + }; + + // finders ------------------------------------------------------------------- + const findAssigneesInputs = () => + document.querySelectorAll('input[name="merge_request[assignee_ids][]'); + const findAssigneesInputsModel = () => + Array.from(findAssigneesInputs()).map((input) => ({ + value: input.value, + dataset: { ...input.dataset }, + })); + const findUserSearchButton = () => document.querySelector('.js-user-search'); + const findDropdownItem = ({ id }) => document.querySelector(`li[data-user-id="${id}"] a`); + const findDropdownItemsModel = () => + Array.from(document.querySelectorAll('.dropdown-content li')).map((el) => { + if (el.classList.contains('divider')) { + return { + type: 'divider', + }; + } else if (el.classList.contains('dropdown-header')) { + return { + type: 'dropdown-header', + text: el.textContent, + }; + } + + return { + type: 'user', + userId: el.dataset.userId, + }; + }); + + // arrange/act helpers ------------------------------------------------------- + const setAssignees = (...users) => { + findAssigneesInputs().forEach((x) => x.remove()); + + const container = document.querySelector('.js-sidebar-assignee-data'); + + container.prepend( + ...users.map((user) => { + const input = document.createElement('input'); + input.name = 'merge_request[assignee_ids][]'; + input.value = user.id.toString(); + input.setAttribute('data-avatar-url', user.avatar_url); + input.setAttribute('data-name', user.name); + input.setAttribute('data-username', user.username); + input.setAttribute('data-can-merge', user.can_merge); + return input; + }), + ); + }; + const toggleDropdown = () => findUserSearchButton().click(); + const waitForDropdownItems = () => + waitFor(() => expect(findDropdownItem(AUTOCOMPLETE_USERS[0])).not.toBeNull()); + + // assertion helpers --------------------------------------------------------- + const createUnassignedExpectation = () => { + return [ + { type: 'user', userId: '0' }, + { type: 'divider' }, + ...AUTOCOMPLETE_USERS.map((x) => ({ type: 'user', userId: x.id.toString() })), + ]; + }; + const createAssignedExpectation = (...selectedUsers) => { + const selectedIds = new Set(selectedUsers.map((x) => x.id)); + const unselectedUsers = AUTOCOMPLETE_USERS.filter((x) => !selectedIds.has(x.id)); + + return [ + { type: 'user', userId: '0' }, + { type: 'divider' }, + { type: 'dropdown-header', text: 'Assignee(s)' }, + ...selectedUsers.map((x) => ({ type: 'user', userId: x.id.toString() })), + { type: 'divider' }, + ...unselectedUsers.map((x) => ({ type: 'user', userId: x.id.toString() })), + ]; + }; + + beforeEach(() => { + const rootEl = document.createElement('div'); + rootEl.innerHTML = USER_SEARCH_HTML; + document.body.appendChild(rootEl); + + mock = new MockAdapter(axios); + mock.onGet('/-/autocomplete/users.json').reply(200, cloneDeep(AUTOCOMPLETE_USERS)); + }); + + afterEach(() => { + document.body.innerHTML = ''; + subject = null; + }); + + describe('when opened', () => { + beforeEach(async () => { + createSubject(); + + toggleDropdown(); + await waitForDropdownItems(); + }); + + it('shows users', () => { + expect(findDropdownItemsModel()).toEqual(createUnassignedExpectation()); + }); + + describe('when users are selected', () => { + const selectedUsers = [AUTOCOMPLETE_USERS[2], AUTOCOMPLETE_USERS[4]]; + const expectation = createAssignedExpectation(...selectedUsers); + + beforeEach(() => { + selectedUsers.forEach((user) => { + findDropdownItem(user).click(); + }); + }); + + it('shows assignee', () => { + expect(findDropdownItemsModel()).toEqual(expectation); + }); + + it('shows assignee even after close and open', () => { + toggleDropdown(); + toggleDropdown(); + + expect(findDropdownItemsModel()).toEqual(expectation); + }); + + it('updates field', () => { + expect(findAssigneesInputsModel()).toEqual( + selectedUsers.map((user) => ({ + value: user.id.toString(), + dataset: { + approved: user.approved.toString(), + avatar_url: user.avatar_url, + can_merge: user.can_merge.toString(), + can_update_merge_request: user.can_update_merge_request.toString(), + id: user.id.toString(), + name: user.name, + show_status: user.show_status.toString(), + state: user.state, + username: user.username, + web_url: user.web_url, + }, + })), + ); + }); + }); + }); + + describe('with preselected user and opened', () => { + const expectation = createAssignedExpectation(AUTOCOMPLETE_USERS[0]); + + beforeEach(async () => { + setAssignees(AUTOCOMPLETE_USERS[0]); + + createSubject(); + + toggleDropdown(); + await waitForDropdownItems(); + }); + + it('shows users', () => { + expect(findDropdownItemsModel()).toEqual(expectation); + }); + + // Regression test for https://gitlab.com/gitlab-org/gitlab/-/issues/325991 + describe('when closed and reopened', () => { + beforeEach(() => { + toggleDropdown(); + toggleDropdown(); + }); + + it('shows users', () => { + expect(findDropdownItemsModel()).toEqual(expectation); + }); + }); + }); +}); diff --git a/spec/frontend/vue_alerts_spec.js b/spec/frontend/vue_alerts_spec.js index 16eb2d44e4d..05b73415544 100644 --- a/spec/frontend/vue_alerts_spec.js +++ b/spec/frontend/vue_alerts_spec.js @@ -42,15 +42,17 @@ describe('VueAlerts', () => { const findJsHooks = () => document.querySelectorAll('.js-vue-alert'); const findAlerts = () => document.querySelectorAll('.gl-alert'); - const findAlertDismiss = (alert) => alert.querySelector('.gl-alert-dismiss'); + const findAlertDismiss = (alert) => alert.querySelector('.gl-dismiss-btn'); const serializeAlert = (alert) => ({ title: alert.querySelector('.gl-alert-title').textContent.trim(), html: alert.querySelector('.gl-alert-body div').innerHTML, - dismissible: Boolean(alert.querySelector('.gl-alert-dismiss')), + dismissible: Boolean(alert.querySelector('.gl-dismiss-btn')), primaryButtonText: alert.querySelector('.gl-alert-action').textContent.trim(), primaryButtonLink: alert.querySelector('.gl-alert-action').href, - variant: [...alert.classList].find((x) => x.match('gl-alert-')).replace('gl-alert-', ''), + variant: [...alert.classList] + .find((x) => x.match(/gl-alert-(?!not-dismissible)/)) + .replace('gl-alert-', ''), }); it('starts with only JsHooks', () => { diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_author_time_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_author_time_spec.js index 78efcb6e695..8fd93809e01 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_author_time_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_author_time_spec.js @@ -1,42 +1,43 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { shallowMount } from '@vue/test-utils'; +import MrWidgetAuthor from '~/vue_merge_request_widget/components/mr_widget_author.vue'; import MrWidgetAuthorTime from '~/vue_merge_request_widget/components/mr_widget_author_time.vue'; describe('MrWidgetAuthorTime', () => { - let vm; + let wrapper; + + const defaultProps = { + actionText: 'Merged by', + author: { + name: 'Administrator', + username: 'root', + webUrl: 'http://localhost:3000/root', + avatarUrl: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }, + dateTitle: '2017-03-23T23:02:00.807Z', + dateReadable: '12 hours ago', + }; beforeEach(() => { - const Component = Vue.extend(MrWidgetAuthorTime); - - vm = mountComponent(Component, { - actionText: 'Merged by', - author: { - name: 'Administrator', - username: 'root', - webUrl: 'http://localhost:3000/root', - avatarUrl: - 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - }, - dateTitle: '2017-03-23T23:02:00.807Z', - dateReadable: '12 hours ago', + wrapper = shallowMount(MrWidgetAuthorTime, { + propsData: defaultProps, }); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('renders provided action text', () => { - expect(vm.$el.textContent).toContain('Merged by'); + expect(wrapper.text()).toContain('Merged by'); }); it('renders author', () => { - expect(vm.$el.textContent).toContain('Administrator'); + expect(wrapper.find(MrWidgetAuthor).props('author')).toStrictEqual(defaultProps.author); }); it('renders provided time', () => { - expect(vm.$el.querySelector('time').getAttribute('title')).toEqual('2017-03-23T23:02:00.807Z'); + expect(wrapper.find('time').attributes('title')).toBe('2017-03-23T23:02:00.807Z'); - expect(vm.$el.querySelector('time').textContent.trim()).toEqual('12 hours ago'); + expect(wrapper.find('time').text().trim()).toBe('12 hours ago'); }); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js index db884dfe015..eadf07e54fb 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js @@ -1,38 +1,35 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import headerComponent from '~/vue_merge_request_widget/components/mr_widget_header.vue'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import Header from '~/vue_merge_request_widget/components/mr_widget_header.vue'; describe('MRWidgetHeader', () => { - let vm; - let Component; + let wrapper; - beforeEach(() => { - Component = Vue.extend(headerComponent); - }); + const createComponent = (propsData = {}) => { + wrapper = shallowMount(Header, { + propsData, + }); + }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); gon.relative_url_root = ''; }); const expectDownloadDropdownItems = () => { - const downloadEmailPatchesEl = vm.$el.querySelector('.js-download-email-patches'); - const downloadPlainDiffEl = vm.$el.querySelector('.js-download-plain-diff'); - - expect(downloadEmailPatchesEl.innerText.trim()).toEqual('Email patches'); - expect(downloadEmailPatchesEl.querySelector('a').getAttribute('href')).toEqual( - '/mr/email-patches', - ); - expect(downloadPlainDiffEl.innerText.trim()).toEqual('Plain diff'); - expect(downloadPlainDiffEl.querySelector('a').getAttribute('href')).toEqual( - '/mr/plainDiffPath', - ); + const downloadEmailPatchesEl = wrapper.find('.js-download-email-patches'); + const downloadPlainDiffEl = wrapper.find('.js-download-plain-diff'); + + expect(downloadEmailPatchesEl.text().trim()).toBe('Email patches'); + expect(downloadEmailPatchesEl.attributes('href')).toBe('/mr/email-patches'); + expect(downloadPlainDiffEl.text().trim()).toBe('Plain diff'); + expect(downloadPlainDiffEl.attributes('href')).toBe('/mr/plainDiffPath'); }; describe('computed', () => { describe('shouldShowCommitsBehindText', () => { it('return true when there are divergedCommitsCount', () => { - vm = mountComponent(Component, { + createComponent({ mr: { divergedCommitsCount: 12, sourceBranch: 'mr-widget-refactor', @@ -42,11 +39,11 @@ describe('MRWidgetHeader', () => { }, }); - expect(vm.shouldShowCommitsBehindText).toEqual(true); + expect(wrapper.vm.shouldShowCommitsBehindText).toBe(true); }); it('returns false where there are no divergedComits count', () => { - vm = mountComponent(Component, { + createComponent({ mr: { divergedCommitsCount: 0, sourceBranch: 'mr-widget-refactor', @@ -56,13 +53,13 @@ describe('MRWidgetHeader', () => { }, }); - expect(vm.shouldShowCommitsBehindText).toEqual(false); + expect(wrapper.vm.shouldShowCommitsBehindText).toBe(false); }); }); describe('commitsBehindText', () => { it('returns singular when there is one commit', () => { - vm = mountComponent(Component, { + createComponent({ mr: { divergedCommitsCount: 1, sourceBranch: 'mr-widget-refactor', @@ -73,13 +70,13 @@ describe('MRWidgetHeader', () => { }, }); - expect(vm.commitsBehindText).toEqual( + expect(wrapper.vm.commitsBehindText).toBe( 'The source branch is <a href="/foo/bar/master">1 commit behind</a> the target branch', ); }); it('returns plural when there is more than one commit', () => { - vm = mountComponent(Component, { + createComponent({ mr: { divergedCommitsCount: 2, sourceBranch: 'mr-widget-refactor', @@ -90,7 +87,7 @@ describe('MRWidgetHeader', () => { }, }); - expect(vm.commitsBehindText).toEqual( + expect(wrapper.vm.commitsBehindText).toBe( 'The source branch is <a href="/foo/bar/master">2 commits behind</a> the target branch', ); }); @@ -100,7 +97,7 @@ describe('MRWidgetHeader', () => { describe('template', () => { describe('common elements', () => { beforeEach(() => { - vm = mountComponent(Component, { + createComponent({ mr: { divergedCommitsCount: 12, sourceBranch: 'mr-widget-refactor', @@ -118,17 +115,17 @@ describe('MRWidgetHeader', () => { }); it('renders source branch link', () => { - expect(vm.$el.querySelector('.js-source-branch').innerHTML).toEqual( + expect(wrapper.find('.js-source-branch').html()).toContain( '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', ); }); it('renders clipboard button', () => { - expect(vm.$el.querySelector('[data-testid="mr-widget-copy-clipboard"]')).not.toEqual(null); + expect(wrapper.find('[data-testid="mr-widget-copy-clipboard"]')).not.toBe(null); }); it('renders target branch', () => { - expect(vm.$el.querySelector('.js-target-branch').textContent.trim()).toEqual('master'); + expect(wrapper.find('.js-target-branch').text().trim()).toBe('master'); }); }); @@ -151,71 +148,68 @@ describe('MRWidgetHeader', () => { targetProjectFullPath: 'gitlab-org/gitlab-ce', }; - afterEach(() => { - vm.$destroy(); - }); - beforeEach(() => { - vm = mountComponent(Component, { + createComponent({ mr: { ...mrDefaultOptions }, }); }); it('renders checkout branch button with modal trigger', () => { - const button = vm.$el.querySelector('.js-check-out-branch'); + const button = wrapper.find('.js-check-out-branch'); - expect(button.textContent.trim()).toBe('Check out branch'); + expect(button.text().trim()).toBe('Check out branch'); }); - it('renders web ide button', () => { - const button = vm.$el.querySelector('.js-web-ide'); + it('renders web ide button', async () => { + const button = wrapper.find('.js-web-ide'); - expect(button.textContent.trim()).toEqual('Open in Web IDE'); - expect(button.classList.contains('disabled')).toBe(false); - expect(button.getAttribute('href')).toEqual( + await nextTick(); + + expect(button.text().trim()).toBe('Open in Web IDE'); + expect(button.classes('disabled')).toBe(false); + expect(button.attributes('href')).toBe( '/-/ide/project/root/gitlab-ce/merge_requests/1?target_project=gitlab-org%2Fgitlab-ce', ); }); - it('renders web ide button in disabled state with no href', () => { + it('renders web ide button in disabled state with no href', async () => { const mr = { ...mrDefaultOptions, canPushToSourceBranch: false }; - vm = mountComponent(Component, { mr }); + createComponent({ mr }); + + await nextTick(); - const link = vm.$el.querySelector('.js-web-ide'); + const link = wrapper.find('.js-web-ide'); - expect(link.classList.contains('disabled')).toBe(true); - expect(link.getAttribute('href')).toBeNull(); + expect(link.attributes('disabled')).toBe('true'); + expect(link.attributes('href')).toBeUndefined(); }); - it('renders web ide button with blank query string if target & source project branch', (done) => { - vm.mr.targetProjectFullPath = 'root/gitlab-ce'; + it('renders web ide button with blank query string if target & source project branch', async () => { + createComponent({ mr: { ...mrDefaultOptions, targetProjectFullPath: 'root/gitlab-ce' } }); - vm.$nextTick(() => { - const button = vm.$el.querySelector('.js-web-ide'); + await nextTick(); - expect(button.textContent.trim()).toEqual('Open in Web IDE'); - expect(button.getAttribute('href')).toEqual( - '/-/ide/project/root/gitlab-ce/merge_requests/1?target_project=', - ); + const button = wrapper.find('.js-web-ide'); - done(); - }); + expect(button.text().trim()).toBe('Open in Web IDE'); + expect(button.attributes('href')).toBe( + '/-/ide/project/root/gitlab-ce/merge_requests/1?target_project=', + ); }); - it('renders web ide button with relative URL', (done) => { + it('renders web ide button with relative URL', async () => { gon.relative_url_root = '/gitlab'; - vm.mr.iid = 2; - vm.$nextTick(() => { - const button = vm.$el.querySelector('.js-web-ide'); + createComponent({ mr: { ...mrDefaultOptions, iid: 2 } }); - expect(button.textContent.trim()).toEqual('Open in Web IDE'); - expect(button.getAttribute('href')).toEqual( - '/gitlab/-/ide/project/root/gitlab-ce/merge_requests/2?target_project=gitlab-org%2Fgitlab-ce', - ); + await nextTick(); - done(); - }); + const button = wrapper.find('.js-web-ide'); + + expect(button.text().trim()).toBe('Open in Web IDE'); + expect(button.attributes('href')).toBe( + '/gitlab/-/ide/project/root/gitlab-ce/merge_requests/2?target_project=gitlab-org%2Fgitlab-ce', + ); }); it('renders download dropdown with links', () => { @@ -225,7 +219,7 @@ describe('MRWidgetHeader', () => { describe('with a closed merge request', () => { beforeEach(() => { - vm = mountComponent(Component, { + createComponent({ mr: { divergedCommitsCount: 12, sourceBranch: 'mr-widget-refactor', @@ -243,9 +237,9 @@ describe('MRWidgetHeader', () => { }); it('does not render checkout branch button with modal trigger', () => { - const button = vm.$el.querySelector('.js-check-out-branch'); + const button = wrapper.find('.js-check-out-branch'); - expect(button).toEqual(null); + expect(button.exists()).toBe(false); }); it('renders download dropdown with links', () => { @@ -255,7 +249,7 @@ describe('MRWidgetHeader', () => { describe('without diverged commits', () => { beforeEach(() => { - vm = mountComponent(Component, { + createComponent({ mr: { divergedCommitsCount: 0, sourceBranch: 'mr-widget-refactor', @@ -273,13 +267,13 @@ describe('MRWidgetHeader', () => { }); it('does not render diverged commits info', () => { - expect(vm.$el.querySelector('.diverged-commits-count')).toEqual(null); + expect(wrapper.find('.diverged-commits-count').exists()).toBe(false); }); }); describe('with diverged commits', () => { beforeEach(() => { - vm = mountComponent(Component, { + createComponent({ mr: { divergedCommitsCount: 12, sourceBranch: 'mr-widget-refactor', @@ -297,17 +291,13 @@ describe('MRWidgetHeader', () => { }); it('renders diverged commits info', () => { - expect(vm.$el.querySelector('.diverged-commits-count').textContent).toEqual( + expect(wrapper.find('.diverged-commits-count').text().trim()).toBe( 'The source branch is 12 commits behind the target branch', ); - expect(vm.$el.querySelector('.diverged-commits-count a').textContent).toEqual( - '12 commits behind', - ); - - expect(vm.$el.querySelector('.diverged-commits-count a')).toHaveAttr( - 'href', - vm.mr.targetBranchPath, + expect(wrapper.find('.diverged-commits-count a').text().trim()).toBe('12 commits behind'); + expect(wrapper.find('.diverged-commits-count a').attributes('href')).toBe( + wrapper.vm.mr.targetBranchPath, ); }); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js index 28492018600..924dc37aab9 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js @@ -1,6 +1,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; import { trimText } from 'helpers/text_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; import PipelineStage from '~/pipelines/components/pipelines_list/pipeline_stage.vue'; import PipelineComponent from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue'; @@ -22,27 +23,31 @@ describe('MRWidgetPipeline', () => { 'Could not retrieve the pipeline status. For troubleshooting steps, read the documentation.'; const monitoringMessage = 'Checking pipeline status.'; - const findCIErrorMessage = () => wrapper.find('[data-testid="ci-error-message"]'); - const findPipelineID = () => wrapper.find('[data-testid="pipeline-id"]'); - const findPipelineInfoContainer = () => wrapper.find('[data-testid="pipeline-info-container"]'); - const findCommitLink = () => wrapper.find('[data-testid="commit-link"]'); - const findPipelineMiniGraph = () => wrapper.find(PipelineMiniGraph); - const findAllPipelineStages = () => wrapper.findAll(PipelineStage); - const findPipelineCoverage = () => wrapper.find('[data-testid="pipeline-coverage"]'); - const findPipelineCoverageDelta = () => wrapper.find('[data-testid="pipeline-coverage-delta"]'); + const findCIErrorMessage = () => wrapper.findByTestId('ci-error-message'); + const findPipelineID = () => wrapper.findByTestId('pipeline-id'); + const findPipelineInfoContainer = () => wrapper.findByTestId('pipeline-info-container'); + const findCommitLink = () => wrapper.findByTestId('commit-link'); + const findPipelineFinishedAt = () => wrapper.findByTestId('finished-at'); + const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph); + const findAllPipelineStages = () => wrapper.findAllComponents(PipelineStage); + const findPipelineCoverage = () => wrapper.findByTestId('pipeline-coverage'); + const findPipelineCoverageDelta = () => wrapper.findByTestId('pipeline-coverage-delta'); const findPipelineCoverageTooltipText = () => - wrapper.find('[data-testid="pipeline-coverage-tooltip"]').text(); - const findMonitoringPipelineMessage = () => - wrapper.find('[data-testid="monitoring-pipeline-message"]'); - const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + wrapper.findByTestId('pipeline-coverage-tooltip').text(); + const findPipelineCoverageDeltaTooltipText = () => + wrapper.findByTestId('pipeline-coverage-delta-tooltip').text(); + const findMonitoringPipelineMessage = () => wrapper.findByTestId('monitoring-pipeline-message'); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const createWrapper = (props = {}, mountFn = shallowMount) => { - wrapper = mountFn(PipelineComponent, { - propsData: { - ...defaultProps, - ...props, - }, - }); + wrapper = extendedWrapper( + mountFn(PipelineComponent, { + propsData: { + ...defaultProps, + ...props, + }, + }), + ); }; afterEach(() => { @@ -87,6 +92,13 @@ describe('MRWidgetPipeline', () => { expect(findCommitLink().attributes('href')).toBe(mockData.pipeline.commit.commit_path); }); + it('should render pipeline finished timestamp', () => { + expect(findPipelineFinishedAt().attributes()).toMatchObject({ + title: 'Apr 7, 2017 2:00pm GMT+0000', + datetime: mockData.pipeline.details.finished_at, + }); + }); + it('should render pipeline graph', () => { expect(findPipelineMiniGraph().exists()).toBe(true); expect(findAllPipelineStages()).toHaveLength(mockData.pipeline.details.stages.length); @@ -94,7 +106,9 @@ describe('MRWidgetPipeline', () => { describe('should render pipeline coverage information', () => { it('should render coverage percentage', () => { - expect(findPipelineCoverage().text()).toMatch(`Coverage ${mockData.pipeline.coverage}%`); + expect(findPipelineCoverage().text()).toMatch( + `Test coverage ${mockData.pipeline.coverage}%`, + ); }); it('should render coverage delta', () => { @@ -102,24 +116,9 @@ describe('MRWidgetPipeline', () => { expect(findPipelineCoverageDelta().text()).toBe(`(${mockData.pipelineCoverageDelta}%)`); }); - it('coverage delta should have no special style if there is no coverage change', () => { - createWrapper({ pipelineCoverageDelta: '0' }); - expect(findPipelineCoverageDelta().classes()).toEqual([]); - }); - - it('coverage delta should have text-success style if coverage increased', () => { - createWrapper({ pipelineCoverageDelta: '10' }); - expect(findPipelineCoverageDelta().classes()).toEqual(['text-success']); - }); - - it('coverage delta should have text-danger style if coverage increased', () => { - createWrapper({ pipelineCoverageDelta: '-10' }); - expect(findPipelineCoverageDelta().classes()).toEqual(['text-danger']); - }); - it('should render tooltip for jobs contributing to code coverage', () => { const tooltipText = findPipelineCoverageTooltipText(); - const expectedDescription = `Coverage value for this pipeline was calculated by averaging the resulting coverage values of ${mockData.buildsWithCoverage.length} jobs.`; + const expectedDescription = `Test coverage value for this pipeline was calculated by averaging the resulting coverage values of ${mockData.buildsWithCoverage.length} jobs.`; expect(tooltipText).toContain(expectedDescription); }); @@ -132,6 +131,26 @@ describe('MRWidgetPipeline', () => { expect(tooltipText).toContain(`${build.name} (${build.coverage}%)`); }, ); + + describe.each` + style | coverageState | coverageChangeText | styleClass | pipelineCoverageDelta + ${'no special'} | ${'the same'} | ${'not change'} | ${''} | ${'0'} + ${'success'} | ${'increased'} | ${'increase'} | ${'text-success'} | ${'10'} + ${'danger'} | ${'decreased'} | ${'decrease'} | ${'text-danger'} | ${'-10'} + `( + 'if test coverage is $coverageState', + ({ style, styleClass, coverageChangeText, pipelineCoverageDelta }) => { + it(`coverage delta should have ${style}`, () => { + createWrapper({ pipelineCoverageDelta }); + expect(findPipelineCoverageDelta().classes()).toEqual(styleClass ? [styleClass] : []); + }); + + it(`coverage delta tooltip should say that the coverage will ${coverageChangeText}`, () => { + createWrapper({ pipelineCoverageDelta }); + expect(findPipelineCoverageDeltaTooltipText()).toContain(coverageChangeText); + }); + }, + ); }); }); @@ -163,7 +182,7 @@ describe('MRWidgetPipeline', () => { }); it('should render coverage information', () => { - expect(findPipelineCoverage().text()).toMatch(`Coverage ${mockData.pipeline.coverage}%`); + expect(findPipelineCoverage().text()).toMatch(`Test coverage ${mockData.pipeline.coverage}%`); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js index 4dd1bd2aa9c..1af96717b56 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js @@ -28,11 +28,11 @@ function convertPropsToGraphqlState(props) { }; } -function factory(propsData) { +function factory(propsData, stateOverride = {}) { let state = {}; if (mergeRequestWidgetGraphqlEnabled) { - state = convertPropsToGraphqlState(propsData); + state = { ...convertPropsToGraphqlState(propsData), ...stateOverride }; } wrapper = extendedWrapper( @@ -125,7 +125,7 @@ describe('MRWidgetAutoMergeEnabled', () => { }, ); - it('should return false when shouldRemoveSourceBranch set to false', () => { + it('should not find "Delete" button when shouldRemoveSourceBranch set to true', () => { factory({ ...defaultMrProps(), shouldRemoveSourceBranch: true, @@ -134,6 +134,29 @@ describe('MRWidgetAutoMergeEnabled', () => { expect(wrapper.findByTestId('removeSourceBranchButton').exists()).toBe(false); }); + it('should find "Delete" button when shouldRemoveSourceBranch overrides state.forceRemoveSourceBranch', () => { + factory( + { + ...defaultMrProps(), + shouldRemoveSourceBranch: false, + }, + { + forceRemoveSourceBranch: true, + }, + ); + + expect(wrapper.findByTestId('removeSourceBranchButton').exists()).toBe(true); + }); + + it('should find "Delete" button when shouldRemoveSourceBranch set to false', () => { + factory({ + ...defaultMrProps(), + shouldRemoveSourceBranch: false, + }); + + expect(wrapper.findByTestId('removeSourceBranchButton').exists()).toBe(true); + }); + it('should return false if user is not able to remove the source branch', () => { factory({ ...defaultMrProps(), diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js index dc2f227b29c..fee78d3af94 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js @@ -1,4 +1,3 @@ -import { GlPopover } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; import { removeBreakLine } from 'helpers/text_helper'; @@ -10,7 +9,6 @@ describe('MRWidgetConflicts', () => { let mergeRequestWidgetGraphql = null; const path = '/conflicts'; - const findPopover = () => wrapper.find(GlPopover); const findResolveButton = () => wrapper.findByTestId('resolve-conflicts-button'); const findMergeLocalButton = () => wrapper.findByTestId('merge-locally-button'); @@ -219,12 +217,8 @@ describe('MRWidgetConflicts', () => { }); }); - it('sets resolve button as disabled', () => { - expect(findResolveButton().attributes('disabled')).toBe('true'); - }); - - it('shows the popover', () => { - expect(findPopover().exists()).toBe(true); + it('should not allow you to resolve the conflicts', () => { + expect(findResolveButton().exists()).toBe(false); }); }); @@ -241,12 +235,9 @@ describe('MRWidgetConflicts', () => { }); }); - it('sets resolve button as disabled', () => { - expect(findResolveButton().attributes('disabled')).toBe(undefined); - }); - - it('does not show the popover', () => { - expect(findPopover().exists()).toBe(false); + it('should allow you to resolve the conflicts', () => { + expect(findResolveButton().text()).toContain('Resolve conflicts'); + expect(findResolveButton().attributes('href')).toEqual(TEST_HOST); }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js index c1471314c4a..6d8e7056366 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js @@ -1,69 +1,67 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import failedToMergeComponent from '~/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue'; +import MrWidgetFailedToMerge from '~/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue'; import eventHub from '~/vue_merge_request_widget/event_hub'; describe('MRWidgetFailedToMerge', () => { const dummyIntervalId = 1337; - let Component; - let mr; - let vm; + let wrapper; + + const createComponent = (props = {}, data = {}) => { + wrapper = shallowMount(MrWidgetFailedToMerge, { + propsData: { + mr: { + mergeError: 'Merge error happened', + }, + ...props, + }, + data() { + return data; + }, + }); + }; beforeEach(() => { - Component = Vue.extend(failedToMergeComponent); jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); jest.spyOn(window, 'setInterval').mockReturnValue(dummyIntervalId); jest.spyOn(window, 'clearInterval').mockImplementation(); - mr = { - mergeError: 'Merge error happened', - }; - vm = mountComponent(Component, { - mr, - }); }); afterEach(() => { - vm.$destroy(); - }); - - it('sets interval to refresh', () => { - expect(window.setInterval).toHaveBeenCalledWith(vm.updateTimer, 1000); - expect(vm.intervalId).toBe(dummyIntervalId); + wrapper.destroy(); }); - it('clears interval when destroying ', () => { - vm.$destroy(); + describe('interval', () => { + it('sets interval to refresh', () => { + createComponent(); - expect(window.clearInterval).toHaveBeenCalledWith(dummyIntervalId); - }); - - describe('computed', () => { - describe('timerText', () => { - it('should return correct timer text', () => { - expect(vm.timerText).toEqual('Refreshing in 10 seconds to show the updated status...'); + expect(window.setInterval).toHaveBeenCalledWith(wrapper.vm.updateTimer, 1000); + expect(wrapper.vm.intervalId).toBe(dummyIntervalId); + }); - vm.timer = 1; + it('clears interval when destroying ', () => { + createComponent(); + wrapper.destroy(); - expect(vm.timerText).toEqual('Refreshing in a second to show the updated status...'); - }); + expect(window.clearInterval).toHaveBeenCalledWith(dummyIntervalId); }); + }); - describe('mergeError', () => { - it('removes forced line breaks', (done) => { - mr.mergeError = 'contains<br />line breaks<br />'; + describe('mergeError', () => { + it('removes forced line breaks', async () => { + createComponent({ mr: { mergeError: 'contains<br />line breaks<br />' } }); - Vue.nextTick() - .then(() => { - expect(vm.mergeError).toBe('contains line breaks.'); - }) - .then(done) - .catch(done.fail); - }); + await nextTick(); + + expect(wrapper.vm.mergeError).toBe('contains line breaks.'); }); }); describe('created', () => { it('should disable polling', () => { + createComponent(); + expect(eventHub.$emit).toHaveBeenCalledWith('DisablePolling'); }); }); @@ -71,11 +69,13 @@ describe('MRWidgetFailedToMerge', () => { describe('methods', () => { describe('refresh', () => { it('should emit event to request component refresh', () => { - expect(vm.isRefreshing).toEqual(false); + createComponent(); + + expect(wrapper.vm.isRefreshing).toBe(false); - vm.refresh(); + wrapper.vm.refresh(); - expect(vm.isRefreshing).toEqual(true); + expect(wrapper.vm.isRefreshing).toBe(true); expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); expect(eventHub.$emit).toHaveBeenCalledWith('EnablePolling'); }); @@ -83,78 +83,76 @@ describe('MRWidgetFailedToMerge', () => { describe('updateTimer', () => { it('should update timer and emit event when timer end', () => { - jest.spyOn(vm, 'refresh').mockImplementation(() => {}); + createComponent(); + + jest.spyOn(wrapper.vm, 'refresh').mockImplementation(() => {}); - expect(vm.timer).toEqual(10); + expect(wrapper.vm.timer).toEqual(10); for (let i = 0; i < 10; i += 1) { - expect(vm.timer).toEqual(10 - i); - vm.updateTimer(); + expect(wrapper.vm.timer).toEqual(10 - i); + wrapper.vm.updateTimer(); } - expect(vm.refresh).toHaveBeenCalled(); + expect(wrapper.vm.refresh).toHaveBeenCalled(); }); }); }); describe('while it is refreshing', () => { - it('renders Refresing now', (done) => { - vm.isRefreshing = true; - - Vue.nextTick(() => { - expect(vm.$el.querySelector('.js-refresh-label').textContent.trim()).toEqual( - 'Refreshing now', - ); - done(); - }); + it('renders Refresing now', async () => { + createComponent({}, { isRefreshing: true }); + + await nextTick(); + + expect(wrapper.find('.js-refresh-label').text().trim()).toBe('Refreshing now'); }); }); describe('while it is not regresing', () => { + beforeEach(() => { + createComponent(); + }); + it('renders warning icon and disabled merge button', () => { - expect(vm.$el.querySelector('.js-ci-status-icon-warning')).not.toBeNull(); - expect( - vm.$el.querySelector('[data-testid="disabled-merge-button"]').getAttribute('disabled'), - ).toEqual('disabled'); + expect(wrapper.find('.js-ci-status-icon-warning')).not.toBeNull(); + expect(wrapper.find(StatusIcon).props('showDisabledButton')).toBe(true); }); it('renders given error', () => { - expect(vm.$el.querySelector('.has-error-message').textContent.trim()).toEqual( - 'Merge error happened.', - ); + expect(wrapper.find('.has-error-message').text().trim()).toBe('Merge error happened.'); }); it('renders refresh button', () => { expect( - vm.$el - .querySelector('[data-testid="merge-request-failed-refresh-button"]') - .textContent.trim(), - ).toEqual('Refresh now'); + wrapper.find('[data-testid="merge-request-failed-refresh-button"]').text().trim(), + ).toBe('Refresh now'); }); it('renders remaining time', () => { - expect(vm.$el.querySelector('.has-custom-error').textContent.trim()).toEqual( + expect(wrapper.find('.has-custom-error').text().trim()).toBe( 'Refreshing in 10 seconds to show the updated status...', ); }); }); - it('should just generic merge failed message if merge_error is not available', (done) => { - vm.mr.mergeError = null; + it('should just generic merge failed message if merge_error is not available', async () => { + createComponent({ mr: { mergeError: null } }); - Vue.nextTick(() => { - expect(vm.$el.innerText).toContain('Merge failed.'); - expect(vm.$el.innerText).not.toContain('Merge error happened.'); - done(); - }); + await nextTick(); + + expect(wrapper.text().trim()).toContain('Merge failed.'); + expect(wrapper.text().trim()).not.toContain('Merge error happened.'); }); - it('should show refresh label when refresh requested', (done) => { - vm.refresh(); - Vue.nextTick(() => { - expect(vm.$el.innerText).not.toContain('Merge failed. Refreshing'); - expect(vm.$el.innerText).toContain('Refreshing now'); - done(); - }); + it('should show refresh label when refresh requested', async () => { + createComponent(); + + wrapper.vm.refresh(); + + await nextTick(); + + expect(wrapper.text().trim()).not.toContain('Merge failed. Refreshing'); + expect(wrapper.text().trim()).toContain('Refreshing now'); }); }); diff --git a/spec/frontend/vue_shared/alert_details/alert_details_spec.js b/spec/frontend/vue_shared/alert_details/alert_details_spec.js index 68bcf1dc491..1fc655f1ebc 100644 --- a/spec/frontend/vue_shared/alert_details/alert_details_spec.js +++ b/spec/frontend/vue_shared/alert_details/alert_details_spec.js @@ -8,7 +8,7 @@ import { joinPaths } from '~/lib/utils/url_utility'; import Tracking from '~/tracking'; import AlertDetails from '~/vue_shared/alert_details/components/alert_details.vue'; import AlertSummaryRow from '~/vue_shared/alert_details/components/alert_summary_row.vue'; -import { SEVERITY_LEVELS } from '~/vue_shared/alert_details/constants'; +import { PAGE_CONFIG, SEVERITY_LEVELS } from '~/vue_shared/alert_details/constants'; import createIssueMutation from '~/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; import mockAlerts from './mocks/alerts.json'; @@ -271,7 +271,13 @@ describe('AlertDetails', () => { }); it('should display a table of raw alert details data', () => { - expect(findDetailsTable().exists()).toBe(true); + const details = findDetailsTable(); + expect(details.exists()).toBe(true); + expect(details.props()).toStrictEqual({ + alert: mockAlert, + statuses: PAGE_CONFIG.OPERATIONS.STATUSES, + loading: false, + }); }); }); diff --git a/spec/frontend/vue_shared/alert_details/alert_status_spec.js b/spec/frontend/vue_shared/alert_details/alert_status_spec.js index a866fc13539..c532f688cbd 100644 --- a/spec/frontend/vue_shared/alert_details/alert_status_spec.js +++ b/spec/frontend/vue_shared/alert_details/alert_status_spec.js @@ -12,6 +12,7 @@ describe('AlertManagementStatus', () => { let wrapper; const findStatusDropdown = () => wrapper.find(GlDropdown); const findFirstStatusOption = () => findStatusDropdown().find(GlDropdownItem); + const findAllStatusOptions = () => findStatusDropdown().findAll(GlDropdownItem); const selectFirstStatusOption = () => { findFirstStatusOption().vm.$emit('click'); @@ -131,6 +132,24 @@ describe('AlertManagementStatus', () => { }); }); + describe('Statuses', () => { + it('renders default translated statuses', () => { + mountComponent({}); + expect(findAllStatusOptions().length).toBe(3); + expect(findFirstStatusOption().text()).toBe('Triggered'); + }); + + it('renders translated statuses', () => { + const status = 'TEST'; + const translatedStatus = 'Test'; + mountComponent({ + props: { alert: { ...mockAlert, status }, statuses: { [status]: translatedStatus } }, + }); + expect(findAllStatusOptions().length).toBe(1); + expect(findFirstStatusOption().text()).toBe(translatedStatus); + }); + }); + describe('Snowplow tracking', () => { beforeEach(() => { jest.spyOn(Tracking, 'event'); diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_spec.js index 70cf2597963..ef75e038bff 100644 --- a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_spec.js +++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_spec.js @@ -76,20 +76,4 @@ describe('Alert Details Sidebar', () => { expect(wrapper.find(SidebarStatus).exists()).toBe(true); }); }); - - describe('the sidebar renders for threat monitoring', () => { - beforeEach(() => { - mock = new MockAdapter(axios); - mountComponent(); - }); - - it('should not render side bar status dropdown', () => { - mountComponent({ - mountMethod: mount, - alert: mockAlert, - provide: { isThreatMonitoringPage: true }, - }); - expect(wrapper.find(SidebarStatus).exists()).toBe(false); - }); - }); }); diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js index f5b9efb4d98..0014957517f 100644 --- a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js +++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js @@ -1,7 +1,9 @@ import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import updateAlertStatusMutation from '~/graphql_shared/mutations/alert_status_update.mutation.graphql'; +import AlertStatus from '~/vue_shared/alert_details/components/alert_status.vue'; import AlertSidebarStatus from '~/vue_shared/alert_details/components/sidebar/sidebar_status.vue'; +import { PAGE_CONFIG } from '~/vue_shared/alert_details/constants'; import mockAlerts from '../mocks/alerts.json'; const mockAlert = mockAlerts[0]; @@ -12,8 +14,16 @@ describe('Alert Details Sidebar Status', () => { const findStatusDropdownItem = () => wrapper.find(GlDropdownItem); const findStatusLoadingIcon = () => wrapper.find(GlLoadingIcon); const findStatusDropdownHeader = () => wrapper.find('[data-testid="dropdown-header"]'); + const findAlertStatus = () => wrapper.findComponent(AlertStatus); + const findStatus = () => wrapper.find('[data-testid="status"]'); - function mountComponent({ data, sidebarCollapsed = true, loading = false, stubs = {} } = {}) { + function mountComponent({ + data, + sidebarCollapsed = true, + loading = false, + stubs = {}, + provide = {}, + } = {}) { wrapper = mount(AlertSidebarStatus, { propsData: { alert: { ...mockAlert }, @@ -32,6 +42,7 @@ describe('Alert Details Sidebar Status', () => { }, }, stubs, + provide, }); } @@ -96,8 +107,24 @@ describe('Alert Details Sidebar Status', () => { jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); findStatusDropdownItem().vm.$emit('click'); expect(findStatusLoadingIcon().exists()).toBe(false); - expect(wrapper.find('[data-testid="status"]').text()).toBe('Triggered'); + expect(findStatus().text()).toBe('Triggered'); }); }); }); + + describe('Statuses', () => { + it('renders default translated statuses', () => { + mountComponent({}); + expect(findAlertStatus().props('statuses')).toBe(PAGE_CONFIG.OPERATIONS.STATUSES); + expect(findStatus().text()).toBe('Triggered'); + }); + + it('renders translated statuses', () => { + const status = 'TEST'; + const statuses = { [status]: 'Test' }; + mountComponent({ data: { alert: { ...mockAlert, status } }, provide: { statuses } }); + expect(findAlertStatus().props('statuses')).toBe(statuses); + expect(findStatus().text()).toBe(statuses.TEST); + }); + }); }); 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 1bf757ea312..bab928318ce 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 @@ -40,6 +40,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` tag="div" > <gl-button-stub + aria-label="Copy URL" buttontextclasses="" category="primary" class="d-inline-flex" @@ -82,6 +83,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` tag="div" > <gl-button-stub + aria-label="Copy URL" buttontextclasses="" category="primary" class="d-inline-flex" diff --git a/spec/frontend/vue_shared/components/alert_details_table_spec.js b/spec/frontend/vue_shared/components/alert_details_table_spec.js index 49b82cb4d4e..03b04a92bdf 100644 --- a/spec/frontend/vue_shared/components/alert_details_table_spec.js +++ b/spec/frontend/vue_shared/components/alert_details_table_spec.js @@ -75,45 +75,62 @@ describe('AlertDetails', () => { }); describe('with table data', () => { - beforeEach(mountComponent); - - it('renders a table', () => { - expect(findTableComponent().exists()).toBe(true); - }); - - it('renders a cell based on alert data', () => { - expect(findTableComponent().text()).toContain('SyntaxError: Invalid or unexpected token'); - }); - - it('should show allowed alert fields', () => { - const fields = findTableKeys(); - - expect(findTableField(fields, 'Iid').exists()).toBe(true); - expect(findTableField(fields, 'Title').exists()).toBe(true); - expect(findTableField(fields, 'Severity').exists()).toBe(true); - expect(findTableField(fields, 'Status').exists()).toBe(true); - expect(findTableField(fields, 'Hosts').exists()).toBe(true); - expect(findTableField(fields, 'Environment').exists()).toBe(true); + describe('default', () => { + beforeEach(mountComponent); + + it('renders a table', () => { + expect(findTableComponent().exists()).toBe(true); + }); + + it('renders a cell based on alert data', () => { + expect(findTableComponent().text()).toContain('SyntaxError: Invalid or unexpected token'); + }); + + it('should show allowed alert fields', () => { + const fields = findTableKeys(); + ['Iid', 'Title', 'Severity', 'Status', 'Hosts', 'Environment'].forEach((field) => { + expect(findTableField(fields, field).exists()).toBe(true); + }); + }); + + it('should not show disallowed alert fields', () => { + const fields = findTableKeys(); + ['Typename', 'Todos', 'Notes', 'Assignees'].forEach((field) => { + expect(findTableField(fields, field).exists()).toBe(false); + }); + }); }); - it('should not show disallowed alert fields', () => { - const fields = findTableKeys(); + describe('environment', () => { + it('should display only the name for the environment', () => { + mountComponent(); + expect(findTableFieldValueByKey('Environment').text()).toBe(environmentName); + }); - expect(findTableField(fields, 'Typename').exists()).toBe(false); - expect(findTableField(fields, 'Todos').exists()).toBe(false); - expect(findTableField(fields, 'Notes').exists()).toBe(false); - expect(findTableField(fields, 'Assignees').exists()).toBe(false); - }); + it('should not display the environment row if there is not data', () => { + environmentData = { name: null, path: null }; + mountComponent(); - it('should display only the name for the environment', () => { - expect(findTableFieldValueByKey('Environment').text()).toBe(environmentName); + expect(findTableFieldValueByKey('Environment').text()).toBeFalsy(); + }); }); - it('should not display the environment row if there is not data', () => { - environmentData = { name: null, path: null }; - mountComponent(); - - expect(findTableFieldValueByKey('Environment').text()).toBeFalsy(); + describe('status', () => { + it('should show the translated status for the default statuses', () => { + mountComponent(); + expect(findTableFieldValueByKey('Status').text()).toBe('Triggered'); + }); + + it('should show the translated status for provided statuses', () => { + const translatedStatus = 'Test'; + mountComponent({ statuses: { TRIGGERED: translatedStatus } }); + expect(findTableFieldValueByKey('Status').text()).toBe(translatedStatus); + }); + + it('should show the provided status if value is not defined in statuses', () => { + mountComponent({ statuses: {} }); + expect(findTableFieldValueByKey('Status').text()).toBe('TRIGGERED'); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap b/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap index 023895099b1..06753044e93 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap +++ b/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap @@ -1,87 +1,88 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Blob Simple Viewer component rendering matches the snapshot 1`] = ` -<div - class="file-content code js-syntax-highlight" - data-qa-selector="file_content" -> +<div> <div - class="line-numbers" + class="file-content code js-syntax-highlight" > - <a - class="diff-line-num js-line-number" - data-line-number="1" - href="#LC1" - id="L1" + <div + class="line-numbers" > - <gl-icon-stub - name="link" - size="12" - /> + <a + class="diff-line-num js-line-number" + data-line-number="1" + href="#LC1" + id="L1" + > + <gl-icon-stub + name="link" + size="12" + /> + + 1 - 1 - - </a> - <a - class="diff-line-num js-line-number" - data-line-number="2" - href="#LC2" - id="L2" - > - <gl-icon-stub - name="link" - size="12" - /> + </a> + <a + class="diff-line-num js-line-number" + data-line-number="2" + href="#LC2" + id="L2" + > + <gl-icon-stub + name="link" + size="12" + /> + + 2 - 2 - - </a> - <a - class="diff-line-num js-line-number" - data-line-number="3" - href="#LC3" - id="L3" - > - <gl-icon-stub - name="link" - size="12" - /> + </a> + <a + class="diff-line-num js-line-number" + data-line-number="3" + href="#LC3" + id="L3" + > + <gl-icon-stub + name="link" + size="12" + /> + + 3 - 3 - - </a> - </div> - - <div - class="blob-content" - > - <pre - class="code highlight" + </a> + </div> + + <div + class="blob-content" > - <code - data-blob-hash="foo-bar" + <pre + class="code highlight" > - <span - id="LC1" + <code + data-blob-hash="foo-bar" > - First - </span> - + <span + id="LC1" + > + First + </span> + - <span - id="LC2" - > - Second - </span> - + <span + id="LC2" + > + Second + </span> + - <span - id="LC3" - > - Third - </span> - </code> - </pre> + <span + id="LC3" + > + Third + </span> + </code> + </pre> + </div> </div> </div> `; diff --git a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js index 9a0616343fe..46d4edad891 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js +++ b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js @@ -1,20 +1,31 @@ import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; import { HIGHLIGHT_CLASS_NAME } from '~/vue_shared/components/blob_viewers/constants'; import SimpleViewer from '~/vue_shared/components/blob_viewers/simple_viewer.vue'; +import EditorLite from '~/vue_shared/components/editor_lite.vue'; describe('Blob Simple Viewer component', () => { let wrapper; const contentMock = `<span id="LC1">First</span>\n<span id="LC2">Second</span>\n<span id="LC3">Third</span>`; const blobHash = 'foo-bar'; - function createComponent(content = contentMock) { + function createComponent( + content = contentMock, + isRawContent = false, + isRefactorFlagEnabled = false, + ) { wrapper = shallowMount(SimpleViewer, { provide: { blobHash, + glFeatures: { + refactorBlobViewer: isRefactorFlagEnabled, + }, }, propsData: { content, type: 'text', + fileName: 'test.js', + isRawContent, }, }); } @@ -83,4 +94,32 @@ describe('Blob Simple Viewer component', () => { }); }); }); + + describe('Vue refactoring to use Source Editor', () => { + const findEditorLite = () => wrapper.find(EditorLite); + + it.each` + doesRender | condition | isRawContent | isRefactorFlagEnabled + ${'Does not'} | ${'rawContent is not specified'} | ${false} | ${true} + ${'Does not'} | ${'feature flag is disabled is not specified'} | ${true} | ${false} + ${'Does not'} | ${'both, the FF and rawContent are not specified'} | ${false} | ${false} + ${'Does'} | ${'both, the FF and rawContent are specified'} | ${true} | ${true} + `( + '$doesRender render Editor Lite component in readonly mode when $condition', + async ({ isRawContent, isRefactorFlagEnabled } = {}) => { + createComponent('raw content', isRawContent, isRefactorFlagEnabled); + await waitForPromises(); + + if (isRawContent && isRefactorFlagEnabled) { + expect(findEditorLite().exists()).toBe(true); + + expect(findEditorLite().props('value')).toBe('raw content'); + expect(findEditorLite().props('fileName')).toBe('test.js'); + expect(findEditorLite().props('editorOptions')).toEqual({ readOnly: true }); + } else { + expect(findEditorLite().exists()).toBe(false); + } + }, + ); + }); }); diff --git a/spec/frontend/vue_shared/components/delete_label_modal_spec.js b/spec/frontend/vue_shared/components/delete_label_modal_spec.js new file mode 100644 index 00000000000..3905690dab4 --- /dev/null +++ b/spec/frontend/vue_shared/components/delete_label_modal_spec.js @@ -0,0 +1,64 @@ +import { GlModal } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import { TEST_HOST } from 'helpers/test_constants'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import DeleteLabelModal from '~/vue_shared/components/delete_label_modal.vue'; + +const MOCK_MODAL_DATA = { + labelName: 'label 1', + subjectName: 'GitLab Org', + destroyPath: `${TEST_HOST}/1`, +}; + +describe('vue_shared/components/delete_label_modal', () => { + let wrapper; + + const createComponent = () => { + wrapper = extendedWrapper( + mount(DeleteLabelModal, { + propsData: { + selector: '.js-test-btn', + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: + '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>', + }), + }, + }), + ); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findModal = () => wrapper.find(GlModal); + const findPrimaryModalButton = () => wrapper.findByTestId('delete-button'); + + describe('template', () => { + describe('when modal data is set', () => { + beforeEach(() => { + createComponent(); + wrapper.vm.labelName = MOCK_MODAL_DATA.labelName; + wrapper.vm.subjectName = MOCK_MODAL_DATA.subjectName; + wrapper.vm.destroyPath = MOCK_MODAL_DATA.destroyPath; + }); + + it('renders GlModal', () => { + expect(findModal().exists()).toBe(true); + }); + + it('displays the label name and subject name', () => { + expect(findModal().text()).toContain( + `${MOCK_MODAL_DATA.labelName} will be permanently deleted from ${MOCK_MODAL_DATA.subjectName}. This cannot be undone`, + ); + }); + + it('passes the destroyPath to the button', () => { + expect(findPrimaryModalButton().attributes('href')).toBe(MOCK_MODAL_DATA.destroyPath); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/deprecated_modal_spec.js b/spec/frontend/vue_shared/components/deprecated_modal_spec.js deleted file mode 100644 index b9793ce2d80..00000000000 --- a/spec/frontend/vue_shared/components/deprecated_modal_spec.js +++ /dev/null @@ -1,73 +0,0 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; - -const modalComponent = Vue.extend(DeprecatedModal); - -describe('DeprecatedModal', () => { - let vm; - - afterEach(() => { - vm.$destroy(); - }); - - describe('props', () => { - describe('without primaryButtonLabel', () => { - beforeEach(() => { - vm = mountComponent(modalComponent, { - primaryButtonLabel: null, - }); - }); - - it('does not render a primary button', () => { - expect(vm.$el.querySelector('.js-primary-button')).toBeNull(); - }); - }); - - describe('with id', () => { - describe('does not render a primary button', () => { - beforeEach(() => { - vm = mountComponent(modalComponent, { - id: 'my-modal', - }); - }); - - it('assigns the id to the modal', () => { - expect(vm.$el.querySelector('#my-modal.modal')).not.toBeNull(); - }); - - it('does not show the modal immediately', () => { - expect(vm.$el.querySelector('#my-modal.modal')).not.toHaveClass('show'); - }); - - it('does not show a backdrop', () => { - expect(vm.$el.querySelector('modal-backdrop')).toBeNull(); - }); - }); - }); - - it('works with data-toggle="modal"', () => { - setFixtures(` - <button id="modal-button" data-toggle="modal" data-target="#my-modal"></button> - <div id="modal-container"></div> - `); - - const modalContainer = document.getElementById('modal-container'); - const modalButton = document.getElementById('modal-button'); - vm = mountComponent( - modalComponent, - { - id: 'my-modal', - }, - modalContainer, - ); - const modalElement = vm.$el.querySelector('#my-modal'); - - expect(modalElement).not.toHaveClass('show'); - - modalButton.click(); - - expect(modalElement).toHaveClass('show'); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/ensure_data_spec.js b/spec/frontend/vue_shared/components/ensure_data_spec.js new file mode 100644 index 00000000000..eef8b452f5f --- /dev/null +++ b/spec/frontend/vue_shared/components/ensure_data_spec.js @@ -0,0 +1,145 @@ +import { GlEmptyState } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { mount } from '@vue/test-utils'; +import ensureData from '~/ensure_data'; + +const mockData = { message: 'Hello there' }; +const defaultOptions = { + parseData: () => mockData, + data: mockData, +}; + +const MockChildComponent = { + inject: ['message'], + render(createElement) { + return createElement('h1', this.message); + }, +}; + +const MockParentComponent = { + components: { + MockChildComponent, + }, + props: { + message: { + type: String, + required: true, + }, + otherProp: { + type: Boolean, + default: false, + required: false, + }, + }, + render(createElement) { + return createElement('div', [this.message, createElement(MockChildComponent)]); + }, +}; + +describe('EnsureData', () => { + let wrapper; + + function findEmptyState() { + return wrapper.findComponent(GlEmptyState); + } + + function findChild() { + return wrapper.findComponent(MockChildComponent); + } + function findParent() { + return wrapper.findComponent(MockParentComponent); + } + + function createComponent(options = defaultOptions) { + return mount(ensureData(MockParentComponent, options)); + } + + beforeEach(() => { + Sentry.captureException = jest.fn(); + }); + + afterEach(() => { + wrapper.destroy(); + Sentry.captureException.mockClear(); + }); + + describe('when parseData throws', () => { + it('should render GlEmptyState', () => { + wrapper = createComponent({ + parseData: () => { + throw new Error(); + }, + }); + + expect(findParent().exists()).toBe(false); + expect(findChild().exists()).toBe(false); + expect(findEmptyState().exists()).toBe(true); + }); + + it('should not log to Sentry when shouldLog=false (default)', () => { + wrapper = createComponent({ + parseData: () => { + throw new Error(); + }, + }); + + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it('should log to Sentry when shouldLog=true', () => { + const error = new Error('Error!'); + wrapper = createComponent({ + parseData: () => { + throw error; + }, + shouldLog: true, + }); + + expect(Sentry.captureException).toHaveBeenCalledWith(error); + }); + }); + + describe('when parseData succeeds', () => { + it('should render MockParentComponent and MockChildComponent', () => { + wrapper = createComponent(); + + expect(findEmptyState().exists()).toBe(false); + expect(findParent().exists()).toBe(true); + expect(findChild().exists()).toBe(true); + }); + + it('enables user to provide data to child components', () => { + wrapper = createComponent(); + + const childComponent = findChild(); + expect(childComponent.text()).toBe(mockData.message); + }); + + it('enables user to override provide data', () => { + const message = 'Another message'; + wrapper = createComponent({ ...defaultOptions, provide: { message } }); + + const childComponent = findChild(); + expect(childComponent.text()).toBe(message); + }); + + it('enables user to pass props to parent component', () => { + wrapper = createComponent(); + + expect(findParent().props()).toMatchObject(mockData); + }); + + it('enables user to override props data', () => { + const props = { message: 'Another message', otherProp: true }; + wrapper = createComponent({ ...defaultOptions, props }); + + expect(findParent().props()).toMatchObject(props); + }); + + it('should not log to Sentry when shouldLog=true', () => { + wrapper = createComponent({ ...defaultOptions, shouldLog: true }); + + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js index 7606b3bd91c..c24528ba4d2 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -3,6 +3,8 @@ import { mockLabels } from 'jest/vue_shared/components/sidebar/labels_select_vue import Api from '~/api'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue'; +import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'; +import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_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'; @@ -59,6 +61,21 @@ export const mockMilestones = [ mockEscapedMilestone, ]; +export const mockEpics = [ + { iid: 1, id: 1, title: 'Foo' }, + { iid: 2, id: 2, title: 'Bar' }, +]; + +export const mockEmoji1 = { + name: 'thumbsup', +}; + +export const mockEmoji2 = { + name: 'star', +}; + +export const mockEmojis = [mockEmoji1, mockEmoji2]; + export const mockBranchToken = { type: 'source_branch', icon: 'branch', @@ -103,6 +120,28 @@ export const mockMilestoneToken = { fetchMilestones: () => Promise.resolve({ data: mockMilestones }), }; +export const mockEpicToken = { + type: 'epic_iid', + icon: 'clock', + title: 'Epic', + unique: true, + symbol: '&', + token: EpicToken, + operators: [{ value: '=', description: 'is', default: 'true' }], + fetchEpics: () => Promise.resolve({ data: mockEpics }), + fetchSingleEpic: () => Promise.resolve({ data: mockEpics[0] }), +}; + +export const mockReactionEmojiToken = { + type: 'my_reaction_emoji', + icon: 'thumb-up', + title: 'My-Reaction', + unique: true, + token: EmojiToken, + operators: [{ value: '=', description: 'is', default: 'true' }], + fetchEmojis: () => Promise.resolve(mockEmojis), +}; + export const mockMembershipToken = { type: 'with_inherited_permissions', icon: 'group', @@ -168,6 +207,14 @@ export const tokenValuePlain = { value: { data: 'foo' }, }; +export const tokenValueEpic = { + type: 'epic_iid', + value: { + operator: '=', + data: '"foo"::&42', + }, +}; + export const mockHistoryItems = [ [tokenValueAuthor, tokenValueLabel, tokenValueMilestone, 'duo'], [tokenValueAuthor, 'si'], diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js new file mode 100644 index 00000000000..231f2f01428 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js @@ -0,0 +1,217 @@ +import { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlFilteredSearchTokenSegment, + GlDropdownDivider, +} from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import axios from '~/lib/utils/axios_utils'; + +import { + DEFAULT_LABEL_NONE, + DEFAULT_LABEL_ANY, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'; + +import { mockReactionEmojiToken, mockEmojis } from '../mock_data'; + +jest.mock('~/flash'); +const GlEmoji = { template: '<img/>' }; +const defaultStubs = { + Portal: true, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, + GlEmoji, +}; + +function createComponent(options = {}) { + const { + config = mockReactionEmojiToken, + value = { data: '' }, + active = false, + stubs = defaultStubs, + } = options; + return mount(EmojiToken, { + propsData: { + config, + value, + active, + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: 'custom-class', + }, + stubs, + }); +} + +describe('EmojiToken', () => { + let mock; + let wrapper; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + describe('computed', () => { + beforeEach(async () => { + wrapper = createComponent({ value: { data: mockEmojis[0].name } }); + + wrapper.setData({ + emojis: mockEmojis, + }); + + await wrapper.vm.$nextTick(); + }); + + describe('currentValue', () => { + it('returns lowercase string for `value.data`', () => { + expect(wrapper.vm.currentValue).toBe(mockEmojis[0].name); + }); + }); + + describe('activeEmoji', () => { + it('returns object for currently present `value.data`', () => { + expect(wrapper.vm.activeEmoji).toEqual(mockEmojis[0]); + }); + }); + }); + + describe('methods', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + describe('fetchEmojiBySearchTerm', () => { + it('calls `config.fetchEmojis` with provided searchTerm param', () => { + jest.spyOn(wrapper.vm.config, 'fetchEmojis'); + + wrapper.vm.fetchEmojiBySearchTerm('foo'); + + expect(wrapper.vm.config.fetchEmojis).toHaveBeenCalledWith('foo'); + }); + + it('sets response to `emojis` when request is successful', () => { + jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockResolvedValue(mockEmojis); + + wrapper.vm.fetchEmojiBySearchTerm('foo'); + + return waitForPromises().then(() => { + expect(wrapper.vm.emojis).toEqual(mockEmojis); + }); + }); + + it('calls `createFlash` with flash error message when request fails', () => { + jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockRejectedValue({}); + + wrapper.vm.fetchEmojiBySearchTerm('foo'); + + return waitForPromises().then(() => { + expect(createFlash).toHaveBeenCalledWith('There was a problem fetching emojis.'); + }); + }); + + it('sets `loading` to false when request completes', () => { + jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockRejectedValue({}); + + wrapper.vm.fetchEmojiBySearchTerm('foo'); + + return waitForPromises().then(() => { + expect(wrapper.vm.loading).toBe(false); + }); + }); + }); + }); + + describe('template', () => { + const defaultEmojis = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; + + beforeEach(async () => { + wrapper = createComponent({ + value: { data: `"${mockEmojis[0].name}"` }, + }); + + wrapper.setData({ + emojis: mockEmojis, + }); + + await wrapper.vm.$nextTick(); + }); + + it('renders gl-filtered-search-token component', () => { + expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true); + }); + + it('renders token item when value is selected', () => { + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + + expect(tokenSegments).toHaveLength(3); // My Reaction, =, "thumbsup" + expect(tokenSegments.at(2).find(GlEmoji).attributes('data-name')).toEqual('thumbsup'); + }); + + it('renders provided defaultEmojis as suggestions', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockReactionEmojiToken, defaultEmojis }, + stubs: { Portal: true, GlEmoji }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(defaultEmojis.length); + defaultEmojis.forEach((emoji, index) => { + expect(suggestions.at(index).text()).toBe(emoji.text); + }); + }); + + it('does not render divider when no defaultEmojis', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockReactionEmojiToken, defaultEmojis: [] }, + stubs: { Portal: true, GlEmoji }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlFilteredSearchSuggestion).exists()).toBe(false); + expect(wrapper.find(GlDropdownDivider).exists()).toBe(false); + }); + + it('renders `DEFAULT_LABEL_NONE` and `DEFAULT_LABEL_ANY` as default suggestions', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockReactionEmojiToken }, + stubs: { Portal: true, GlEmoji }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(2); + expect(suggestions.at(0).text()).toBe(DEFAULT_LABEL_NONE.text); + expect(suggestions.at(1).text()).toBe(DEFAULT_LABEL_ANY.text); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js new file mode 100644 index 00000000000..0c3f9e1363f --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js @@ -0,0 +1,180 @@ +import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; + +import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue'; + +import { mockEpicToken, mockEpics } from '../mock_data'; + +jest.mock('~/flash'); + +const defaultStubs = { + Portal: true, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, +}; + +function createComponent(options = {}) { + const { + config = mockEpicToken, + value = { data: '' }, + active = false, + stubs = defaultStubs, + } = options; + return mount(EpicToken, { + propsData: { + config, + value, + active, + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: 'custom-class', + }, + stubs, + }); +} + +describe('EpicToken', () => { + let mock; + let wrapper; + + beforeEach(() => { + mock = new MockAdapter(axios); + wrapper = createComponent(); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + describe('computed', () => { + beforeEach(async () => { + wrapper = createComponent({ + data: { + epics: mockEpics, + }, + }); + + await wrapper.vm.$nextTick(); + }); + + describe('currentValue', () => { + it.each` + data | id + ${`${mockEpics[0].title}::&${mockEpics[0].iid}`} | ${mockEpics[0].iid} + ${mockEpics[0].iid} | ${mockEpics[0].iid} + ${'foobar'} | ${'foobar'} + `('$data returns $id', async ({ data, id }) => { + wrapper.setProps({ value: { data } }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.currentValue).toBe(id); + }); + }); + + describe('activeEpic', () => { + it('returns object for currently present `value.data`', async () => { + wrapper.setProps({ + value: { data: `${mockEpics[0].iid}` }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.activeEpic).toEqual(mockEpics[0]); + }); + }); + }); + + describe('methods', () => { + describe('fetchEpicsBySearchTerm', () => { + it('calls `config.fetchEpics` with provided searchTerm param', () => { + jest.spyOn(wrapper.vm.config, 'fetchEpics'); + + wrapper.vm.fetchEpicsBySearchTerm('foo'); + + expect(wrapper.vm.config.fetchEpics).toHaveBeenCalledWith('foo'); + }); + + it('sets response to `epics` when request is successful', async () => { + jest.spyOn(wrapper.vm.config, 'fetchEpics').mockResolvedValue({ + data: mockEpics, + }); + + wrapper.vm.fetchEpicsBySearchTerm(); + + await waitForPromises(); + + expect(wrapper.vm.epics).toEqual(mockEpics); + }); + + it('calls `createFlash` with flash error message when request fails', async () => { + jest.spyOn(wrapper.vm.config, 'fetchEpics').mockRejectedValue({}); + + wrapper.vm.fetchEpicsBySearchTerm('foo'); + + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem fetching epics.', + }); + }); + + it('sets `loading` to false when request completes', async () => { + jest.spyOn(wrapper.vm.config, 'fetchEpics').mockRejectedValue({}); + + wrapper.vm.fetchEpicsBySearchTerm('foo'); + + await waitForPromises(); + + expect(wrapper.vm.loading).toBe(false); + }); + }); + + describe('fetchSingleEpic', () => { + it('calls `config.fetchSingleEpic` with provided iid param', async () => { + jest.spyOn(wrapper.vm.config, 'fetchSingleEpic'); + + wrapper.vm.fetchSingleEpic(1); + + expect(wrapper.vm.config.fetchSingleEpic).toHaveBeenCalledWith(1); + + await waitForPromises(); + + expect(wrapper.vm.epics).toEqual([mockEpics[0]]); + }); + }); + }); + + describe('template', () => { + beforeEach(async () => { + wrapper = createComponent({ + value: { data: `${mockEpics[0].iid}` }, + data: { epics: mockEpics }, + }); + + await wrapper.vm.$nextTick(); + }); + + it('renders gl-filtered-search-token component', () => { + expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true); + }); + + it('renders token item when value is selected', () => { + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + + expect(tokenSegments).toHaveLength(3); + expect(tokenSegments.at(2).text()).toBe(`${mockEpics[0].title}::&${mockEpics[0].iid}`); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js index 7676ce10ce0..8528c062426 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js @@ -118,6 +118,22 @@ describe('LabelToken', () => { wrapper = createComponent(); }); + describe('getLabelName', () => { + it('returns value of `name` or `title` property present in provided label param', () => { + let mockLabel = { + title: 'foo', + }; + + expect(wrapper.vm.getLabelName(mockLabel)).toBe(mockLabel.title); + + mockLabel = { + name: 'foo', + }; + + expect(wrapper.vm.getLabelName(mockLabel)).toBe(mockLabel.name); + }); + }); + describe('fetchLabelBySearchTerm', () => { it('calls `config.fetchLabels` with provided searchTerm param', () => { jest.spyOn(wrapper.vm.config, 'fetchLabels'); diff --git a/spec/frontend/vue_shared/components/gl_toggle_vuex_spec.js b/spec/frontend/vue_shared/components/gl_toggle_vuex_spec.js deleted file mode 100644 index ac670b622b1..00000000000 --- a/spec/frontend/vue_shared/components/gl_toggle_vuex_spec.js +++ /dev/null @@ -1,114 +0,0 @@ -import { GlToggle } from '@gitlab/ui'; -import { mount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import GlToggleVuex from '~/vue_shared/components/gl_toggle_vuex.vue'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('GlToggleVuex component', () => { - let wrapper; - let store; - - const findButton = () => wrapper.find('button'); - - const createWrapper = (props = {}) => { - wrapper = mount(GlToggleVuex, { - localVue, - store, - propsData: { - stateProperty: 'toggleState', - ...props, - }, - }); - }; - - beforeEach(() => { - store = new Vuex.Store({ - state: { - toggleState: false, - }, - actions: { - setToggleState: ({ commit }, { key, value }) => commit('setToggleState', { key, value }), - }, - mutations: { - setToggleState: (state, { key, value }) => { - state[key] = value; - }, - }, - }); - createWrapper(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders gl-toggle', () => { - expect(wrapper.find(GlToggle).exists()).toBe(true); - }); - - it('properly computes default value for setAction', () => { - expect(wrapper.props('setAction')).toBe('setToggleState'); - }); - - describe('without a store module', () => { - it('calls action with new value when value changes', () => { - jest.spyOn(store, 'dispatch'); - - findButton().trigger('click'); - expect(store.dispatch).toHaveBeenCalledWith('setToggleState', { - key: 'toggleState', - value: true, - }); - }); - - it('updates store property when value changes', () => { - findButton().trigger('click'); - expect(store.state.toggleState).toBe(true); - }); - }); - - describe('with a store module', () => { - beforeEach(() => { - store = new Vuex.Store({ - modules: { - someModule: { - namespaced: true, - state: { - toggleState: false, - }, - actions: { - setToggleState: ({ commit }, { key, value }) => - commit('setToggleState', { key, value }), - }, - mutations: { - setToggleState: (state, { key, value }) => { - state[key] = value; - }, - }, - }, - }, - }); - - createWrapper({ - storeModule: 'someModule', - }); - }); - - it('calls action with new value when value changes', () => { - jest.spyOn(store, 'dispatch'); - - findButton().trigger('click'); - expect(store.dispatch).toHaveBeenCalledWith('someModule/setToggleState', { - key: 'toggleState', - value: true, - }); - }); - - it('updates store property when value changes', () => { - findButton().trigger('click'); - expect(store.state.someModule.toggleState).toBe(true); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/help_popover_spec.js b/spec/frontend/vue_shared/components/help_popover_spec.js index baf80a8a04e..30c6fa04032 100644 --- a/spec/frontend/vue_shared/components/help_popover_spec.js +++ b/spec/frontend/vue_shared/components/help_popover_spec.js @@ -27,7 +27,6 @@ describe('HelpPopover', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); it('renders a link button with an icon question', () => { @@ -35,17 +34,12 @@ describe('HelpPopover', () => { icon: 'question', variant: 'link', }); - expect(findQuestionButton().attributes().tabindex).toBe('0'); }); it('renders popover that uses the question button as target', () => { expect(findPopover().props().target()).toBe(findQuestionButton().vm.$el); }); - it('triggers popover on hover and focus', () => { - expect(findPopover().props().triggers).toBe('hover focus'); - }); - it('allows rendering title with HTML tags', () => { expect(findPopover().find('strong').exists()).toBe(true); }); @@ -54,6 +48,14 @@ describe('HelpPopover', () => { expect(findPopover().find('b').exists()).toBe(true); }); + describe('without title', () => { + it('does not render title', () => { + buildWrapper({ title: null }); + + expect(findPopover().find('span').exists()).toBe(false); + }); + }); + it('binds other popover options to the popover instance', () => { const placement = 'bottom'; diff --git a/spec/frontend/vue_shared/components/lib/utils/props_utils_spec.js b/spec/frontend/vue_shared/components/lib/utils/props_utils_spec.js new file mode 100644 index 00000000000..f1c9fbb00c9 --- /dev/null +++ b/spec/frontend/vue_shared/components/lib/utils/props_utils_spec.js @@ -0,0 +1,91 @@ +import { propsUnion } from '~/vue_shared/components/lib/utils/props_utils'; + +describe('propsUnion', () => { + const stringRequired = { + type: String, + required: true, + }; + + const stringOptional = { + type: String, + required: false, + }; + + const numberOptional = { + type: Number, + required: false, + }; + + const booleanRequired = { + type: Boolean, + required: true, + }; + + const FooComponent = { + props: { foo: stringRequired }, + }; + + const BarComponent = { + props: { bar: numberOptional }, + }; + + const FooBarComponent = { + props: { + foo: stringRequired, + bar: numberOptional, + }, + }; + + const FooOptionalComponent = { + props: { + foo: stringOptional, + }, + }; + + const QuxComponent = { + props: { + foo: booleanRequired, + qux: stringRequired, + }, + }; + + it('returns an empty object given no components', () => { + expect(propsUnion([])).toEqual({}); + }); + + it('merges non-overlapping props', () => { + expect(propsUnion([FooComponent, BarComponent])).toEqual({ + ...FooComponent.props, + ...BarComponent.props, + }); + }); + + it('merges overlapping props', () => { + expect(propsUnion([FooComponent, BarComponent, FooBarComponent])).toEqual({ + ...FooComponent.props, + ...BarComponent.props, + ...FooBarComponent.props, + }); + }); + + it.each` + components + ${[FooComponent, FooOptionalComponent]} + ${[FooOptionalComponent, FooComponent]} + `('prefers required props over non-required props', ({ components }) => { + expect(propsUnion(components)).toEqual(FooComponent.props); + }); + + it('throws if given props with conflicting types', () => { + expect(() => propsUnion([FooComponent, QuxComponent])).toThrow(/incompatible prop types/); + }); + + it.each` + components + ${[{ props: ['foo', 'bar'] }]} + ${[{ props: { foo: String, bar: Number } }]} + ${[{ props: { foo: {}, bar: {} } }]} + `('throw if given a non-verbose props object', ({ components }) => { + expect(() => propsUnion(components)).toThrow(/expected verbose prop/); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js index 5364e2d5f52..ba2450b56c9 100644 --- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js @@ -1,5 +1,6 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import ApplySuggestion from '~/vue_shared/components/markdown/apply_suggestion.vue'; import SuggestionDiffHeader from '~/vue_shared/components/markdown/suggestion_diff_header.vue'; @@ -16,17 +17,14 @@ const DEFAULT_PROPS = { describe('Suggestion Diff component', () => { let wrapper; - const createComponent = (props, glFeatures = {}) => { + const createComponent = (props) => { wrapper = shallowMount(SuggestionDiffHeader, { propsData: { ...DEFAULT_PROPS, ...props, }, - provide: { - glFeatures: { - batchSuggestions: true, - ...glFeatures, - }, + directives: { + GlTooltip: createMockDirective(), }, }); }; @@ -211,18 +209,6 @@ describe('Suggestion Diff component', () => { }); }); - describe('batchSuggestions feature flag is set to false', () => { - beforeEach(() => { - createComponent({}, { batchSuggestions: false }); - }); - - it('disables add to batch buttons but keeps apply suggestion enabled', () => { - expect(findApplyButton().exists()).toBe(true); - expect(findAddToBatchButton().exists()).toBe(false); - expect(findApplyButton().attributes('disabled')).not.toBe('true'); - }); - }); - describe('canApply is set to false', () => { beforeEach(() => { createComponent({ canApply: false }); @@ -236,15 +222,23 @@ describe('Suggestion Diff component', () => { }); describe('tooltip message for apply button', () => { + const findTooltip = () => getBinding(findApplyButton().element, 'gl-tooltip'); + it('renders correct tooltip message when button is applicable', () => { createComponent(); - expect(wrapper.vm.tooltipMessage).toBe('This also resolves this thread'); + const tooltip = findTooltip(); + + expect(tooltip.modifiers.viewport).toBe(true); + expect(tooltip.value).toBe('This also resolves this thread'); }); it('renders the inapplicable reason in the tooltip when button is not applicable', () => { const inapplicableReason = 'lorem'; createComponent({ canApply: false, inapplicableReason }); - expect(wrapper.vm.tooltipMessage).toBe(inapplicableReason); + const tooltip = findTooltip(); + + expect(tooltip.modifiers.viewport).toBe(true); + expect(tooltip.value).toBe(inapplicableReason); }); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js index e7c31014bfc..eddc4033a65 100644 --- a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js +++ b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js @@ -1,35 +1,75 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import toolbar from '~/vue_shared/components/markdown/toolbar.vue'; +import { mount } from '@vue/test-utils'; +import { isExperimentVariant } from '~/experimentation/utils'; +import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; +import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants'; +import Toolbar from '~/vue_shared/components/markdown/toolbar.vue'; + +jest.mock('~/experimentation/utils', () => ({ isExperimentVariant: jest.fn() })); describe('toolbar', () => { - let vm; - const Toolbar = Vue.extend(toolbar); - const props = { - markdownDocsPath: '', + let wrapper; + + const createMountedWrapper = (props = {}) => { + wrapper = mount(Toolbar, { + propsData: { markdownDocsPath: '', ...props }, + stubs: { 'invite-members-trigger': true }, + }); }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); + isExperimentVariant.mockReset(); }); describe('user can attach file', () => { beforeEach(() => { - vm = mountComponent(Toolbar, props); + createMountedWrapper(); }); it('should render uploading-container', () => { - expect(vm.$el.querySelector('.uploading-container')).not.toBeNull(); + expect(wrapper.vm.$el.querySelector('.uploading-container')).not.toBeNull(); }); }); describe('user cannot attach file', () => { beforeEach(() => { - vm = mountComponent(Toolbar, { ...props, canAttachFile: false }); + createMountedWrapper({ canAttachFile: false }); }); it('should not render uploading-container', () => { - expect(vm.$el.querySelector('.uploading-container')).toBeNull(); + expect(wrapper.vm.$el.querySelector('.uploading-container')).toBeNull(); + }); + }); + + describe('user can invite member', () => { + const findInviteLink = () => wrapper.find(InviteMembersTrigger); + + beforeEach(() => { + isExperimentVariant.mockReturnValue(true); + createMountedWrapper(); + }); + + it('should render the invite members trigger', () => { + expect(findInviteLink().exists()).toBe(true); + }); + + it('should have correct props', () => { + expect(findInviteLink().props().displayText).toBe('Invite Member'); + expect(findInviteLink().props().trackExperiment).toBe(INVITE_MEMBERS_IN_COMMENT); + expect(findInviteLink().props().triggerSource).toBe(INVITE_MEMBERS_IN_COMMENT); + }); + }); + + describe('user can not invite member', () => { + const findInviteLink = () => wrapper.find(InviteMembersTrigger); + + beforeEach(() => { + isExperimentVariant.mockReturnValue(false); + createMountedWrapper(); + }); + + it('should render the invite members trigger', () => { + expect(findInviteLink().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/vue_shared/components/recaptcha_eventhub_spec.js b/spec/frontend/vue_shared/components/recaptcha_eventhub_spec.js deleted file mode 100644 index d86d627886f..00000000000 --- a/spec/frontend/vue_shared/components/recaptcha_eventhub_spec.js +++ /dev/null @@ -1,21 +0,0 @@ -import { eventHub, callbackName } from '~/vue_shared/components/recaptcha_eventhub'; - -describe('reCAPTCHA event hub', () => { - // the following test case currently crashes - // see https://gitlab.com/gitlab-org/gitlab/issues/29192#note_217840035 - // eslint-disable-next-line jest/no-disabled-tests - it.skip('throws an error for overriding the callback', () => { - expect(() => { - window[callbackName] = 'something'; - }).toThrow(); - }); - - it('triggering callback emits a submit event', () => { - const eventHandler = jest.fn(); - eventHub.$once('submit', eventHandler); - - window[callbackName](); - - expect(eventHandler).toHaveBeenCalled(); - }); -}); diff --git a/spec/frontend/vue_shared/components/recaptcha_modal_spec.js b/spec/frontend/vue_shared/components/recaptcha_modal_spec.js deleted file mode 100644 index 8ab65efd388..00000000000 --- a/spec/frontend/vue_shared/components/recaptcha_modal_spec.js +++ /dev/null @@ -1,35 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; - -import { eventHub } from '~/vue_shared/components/recaptcha_eventhub'; - -import RecaptchaModal from '~/vue_shared/components/recaptcha_modal.vue'; - -describe('RecaptchaModal', () => { - const recaptchaFormId = 'recaptcha-form'; - const recaptchaHtml = `<form id="${recaptchaFormId}"></form>`; - - let wrapper; - - const findRecaptchaForm = () => wrapper.find(`#${recaptchaFormId}`).element; - - beforeEach(() => { - wrapper = shallowMount(RecaptchaModal, { - propsData: { - html: recaptchaHtml, - }, - }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('submits the form if event hub emits submit event', () => { - const form = findRecaptchaForm(); - jest.spyOn(form, 'submit').mockImplementation(); - - eventHub.$emit('submit'); - - expect(form.submit).toHaveBeenCalled(); - }); -}); diff --git a/spec/frontend/vue_shared/components/registry/registry_search_spec.js b/spec/frontend/vue_shared/components/registry/registry_search_spec.js index 28bdb275756..f5ef5b3d443 100644 --- a/spec/frontend/vue_shared/components/registry/registry_search_spec.js +++ b/spec/frontend/vue_shared/components/registry/registry_search_spec.js @@ -1,5 +1,6 @@ import { GlSorting, GlSortingItem, GlFilteredSearch } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import component from '~/vue_shared/components/registry/registry_search.vue'; describe('Registry Search', () => { @@ -12,8 +13,18 @@ describe('Registry Search', () => { const defaultProps = { filter: [], sorting: { sort: 'asc', orderBy: 'name' }, - tokens: ['foo'], - sortableFields: [{ label: 'name', orderBy: 'name' }, { label: 'baz' }], + tokens: [{ type: 'foo' }], + sortableFields: [ + { label: 'name', orderBy: 'name' }, + { label: 'baz', orderBy: 'bar' }, + ], + }; + + const defaultQueryChangedPayload = { + foo: '', + orderBy: 'name', + search: [], + sort: 'asc', }; const mountComponent = (propsData = defaultProps) => { @@ -55,20 +66,22 @@ describe('Registry Search', () => { expect(wrapper.emitted('filter:changed')).toEqual([['foo']]); }); - it('emits filter:submit on submit event', () => { + it('emits filter:submit and query:changed on submit event', () => { mountComponent(); findFilteredSearch().vm.$emit('submit'); expect(wrapper.emitted('filter:submit')).toEqual([[]]); + expect(wrapper.emitted('query:changed')).toEqual([[defaultQueryChangedPayload]]); }); - it('emits filter:changed and filter:submit on clear event', () => { + it('emits filter:changed, filter:submit and query:changed on clear event', () => { mountComponent(); findFilteredSearch().vm.$emit('clear'); expect(wrapper.emitted('filter:changed')).toEqual([[[]]]); expect(wrapper.emitted('filter:submit')).toEqual([[]]); + expect(wrapper.emitted('query:changed')).toEqual([[defaultQueryChangedPayload]]); }); it('binds tokens prop', () => { @@ -90,15 +103,47 @@ describe('Registry Search', () => { findPackageListSorting().vm.$emit('sortDirectionChange'); expect(wrapper.emitted('sorting:changed')).toEqual([[{ sort: 'desc' }]]); + expect(wrapper.emitted('query:changed')).toEqual([ + [{ ...defaultQueryChangedPayload, sort: 'desc' }], + ]); }); it('on sort item click emits sorting:changed event ', () => { mountComponent(); - findSortingItems().at(0).vm.$emit('click'); + findSortingItems().at(1).vm.$emit('click'); expect(wrapper.emitted('sorting:changed')).toEqual([ - [{ orderBy: defaultProps.sortableFields[0].orderBy }], + [{ orderBy: defaultProps.sortableFields[1].orderBy }], + ]); + expect(wrapper.emitted('query:changed')).toEqual([ + [{ ...defaultQueryChangedPayload, orderBy: 'bar' }], + ]); + }); + }); + + describe('query string calculation', () => { + const filter = [ + { type: FILTERED_SEARCH_TERM, value: { data: 'one' } }, + { type: FILTERED_SEARCH_TERM, value: { data: 'two' } }, + { type: 'typeOne', value: { data: 'value_one' } }, + { type: 'typeTwo', value: { data: 'value_two' } }, + ]; + + it('aggregates the filter in the correct object', () => { + mountComponent({ ...defaultProps, filter }); + + findFilteredSearch().vm.$emit('submit'); + + expect(wrapper.emitted('query:changed')).toEqual([ + [ + { + ...defaultQueryChangedPayload, + search: ['one', 'two'], + typeOne: 'value_one', + typeTwo: 'value_two', + }, + ], ]); }); }); diff --git a/spec/frontend/vue_shared/components/remove_member_modal_spec.js b/spec/frontend/vue_shared/components/remove_member_modal_spec.js index 78fe6d53eee..ce9de28d53c 100644 --- a/spec/frontend/vue_shared/components/remove_member_modal_spec.js +++ b/spec/frontend/vue_shared/components/remove_member_modal_spec.js @@ -1,13 +1,25 @@ -import { GlFormCheckbox, GlModal } from '@gitlab/ui'; +import { GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; +const mockSchedules = JSON.stringify({ + schedules: [ + { + id: 1, + name: 'Schedule 1', + }, + ], + name: 'User1', +}); + describe('RemoveMemberModal', () => { const memberPath = '/gitlab-org/gitlab-test/-/project_members/90'; let wrapper; const findForm = () => wrapper.find({ ref: 'form' }); - const findGlModal = () => wrapper.find(GlModal); + const findGlModal = () => wrapper.findComponent(GlModal); + const findOnCallSchedulesList = () => wrapper.findComponent(OncallSchedulesList); afterEach(() => { wrapper.destroy(); @@ -15,26 +27,43 @@ describe('RemoveMemberModal', () => { }); describe.each` - state | isAccessRequest | actionText | checkboxTestDescription | checkboxExpected | message - ${'removing a member'} | ${'false'} | ${'Remove member'} | ${'shows a checkbox to allow removal from related issues and MRs'} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} - ${'denying an access request'} | ${'true'} | ${'Deny access request'} | ${'does not show a checkbox'} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} + state | memberType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message | onCallSchedules + ${'removing a group member'} | ${'GroupMember'} | ${false} | ${'false'} | ${'Remove member'} | ${true} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${`{}`} + ${'removing a project member'} | ${'ProjectMember'} | ${false} | ${'false'} | ${'Remove member'} | ${false} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${mockSchedules} + ${'denying an access request'} | ${'ProjectMember'} | ${true} | ${'false'} | ${'Deny access request'} | ${false} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} | ${`{}`} + ${'revoking invite'} | ${'ProjectMember'} | ${false} | ${'true'} | ${'Revoke invite'} | ${false} | ${false} | ${'Are you sure you want to revoke the invitation for foo@bar.com to join the Gitlab Org / Gitlab Test project?'} | ${mockSchedules} `( 'when $state', - ({ actionText, isAccessRequest, message, checkboxTestDescription, checkboxExpected }) => { + ({ + actionText, + memberType, + isAccessRequest, + isInvite, + message, + removeSubMembershipsCheckboxExpected, + unassignIssuablesCheckboxExpected, + onCallSchedules, + }) => { beforeEach(() => { wrapper = shallowMount(RemoveMemberModal, { data() { return { modalData: { isAccessRequest, + isInvite, message, memberPath, + memberType, + onCallSchedules, }, }; }, }); }); + const parsedSchedules = JSON.parse(onCallSchedules); + const isPartOfOncallSchedules = Boolean(isAccessRequest && parsedSchedules.schedules?.length); + it(`has the title ${actionText}`, () => { expect(findGlModal().attributes('title')).toBe(actionText); }); @@ -47,8 +76,24 @@ describe('RemoveMemberModal', () => { expect(wrapper.find('[data-testid=modal-message]').text()).toBe(message); }); - it(`${checkboxTestDescription}`, () => { - expect(wrapper.find(GlFormCheckbox).exists()).toBe(checkboxExpected); + it(`shows ${ + removeSubMembershipsCheckboxExpected ? 'a' : 'no' + } checkbox to remove direct memberships of subgroups/projects`, () => { + expect(wrapper.find('[name=remove_sub_memberships]').exists()).toBe( + removeSubMembershipsCheckboxExpected, + ); + }); + + it(`shows ${ + unassignIssuablesCheckboxExpected ? 'a' : 'no' + } checkbox to allow removal from related issues and MRs`, () => { + expect(wrapper.find('[name=unassign_issuables]').exists()).toBe( + unassignIssuablesCheckboxExpected, + ); + }); + + it(`shows ${isPartOfOncallSchedules ? 'all' : 'no'} related on-call schedules`, () => { + expect(findOnCallSchedulesList().exists()).toBe(isPartOfOncallSchedules); }); it('submits the form when the modal is submitted', () => { diff --git a/spec/frontend/vue_shared/components/runner_instructions/mock_data.js b/spec/frontend/vue_shared/components/runner_instructions/mock_data.js index 01f7f3d49c7..bc1545014d7 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/mock_data.js +++ b/spec/frontend/vue_shared/components/runner_instructions/mock_data.js @@ -98,9 +98,21 @@ export const mockGraphqlInstructions = { data: { runnerSetup: { installInstructions: - "# Download the binary for your system\nsudo curl -L --output /usr/local/bin/gitlab-runner https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64\n\n# Give it permissions to execute\nsudo chmod +x /usr/local/bin/gitlab-runner\n\n# Create a GitLab CI user\nsudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash\n\n# Install and run as service\nsudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner\nsudo gitlab-runner start\n", + '# Install and run as service\nsudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner\nsudo gitlab-runner start', registerInstructions: - 'sudo gitlab-runner register --url http://192.168.1.81:3000/ --registration-token GE5gsjeep_HAtBf9s3Yz', + 'sudo gitlab-runner register --url http://gdk.test:3000/ --registration-token $REGISTRATION_TOKEN', + __typename: 'RunnerSetup', + }, + }, +}; + +export const mockGraphqlInstructionsWindows = { + data: { + runnerSetup: { + installInstructions: + '# Windows runner, then run\n.gitlab-runner.exe install\n.gitlab-runner.exe start', + registerInstructions: + './gitlab-runner.exe register --url http://gdk.test:3000/ --registration-token $REGISTRATION_TOKEN', __typename: 'RunnerSetup', }, }, diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js new file mode 100644 index 00000000000..4033c943b82 --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js @@ -0,0 +1,184 @@ +import { GlAlert, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; +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 getRunnerPlatformsQuery from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql'; +import getRunnerSetupInstructionsQuery from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql'; +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; + +import { + mockGraphqlRunnerPlatforms, + mockGraphqlInstructions, + mockGraphqlInstructionsWindows, +} from './mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('RunnerInstructionsModal component', () => { + let wrapper; + let fakeApollo; + let runnerPlatformsHandler; + let runnerSetupInstructionsHandler; + + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findAlert = () => wrapper.findComponent(GlAlert); + const findPlatformButtons = () => wrapper.findAllByTestId('platform-button'); + const findArchitectureDropdownItems = () => wrapper.findAllByTestId('architecture-dropdown-item'); + const findBinaryInstructions = () => wrapper.findByTestId('binary-instructions'); + const findRegisterCommand = () => wrapper.findByTestId('register-command'); + + const createComponent = () => { + const requestHandlers = [ + [getRunnerPlatformsQuery, runnerPlatformsHandler], + [getRunnerSetupInstructionsQuery, runnerSetupInstructionsHandler], + ]; + + fakeApollo = createMockApollo(requestHandlers); + + wrapper = extendedWrapper( + shallowMount(RunnerInstructionsModal, { + propsData: { + modalId: 'runner-instructions-modal', + }, + localVue, + apolloProvider: fakeApollo, + }), + ); + }; + + beforeEach(async () => { + runnerPlatformsHandler = jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms); + runnerSetupInstructionsHandler = jest.fn().mockResolvedValue(mockGraphqlInstructions); + + createComponent(); + + await nextTick(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should not show alert', () => { + expect(findAlert().exists()).toBe(false); + }); + + it('should contain a number of platforms buttons', () => { + expect(runnerPlatformsHandler).toHaveBeenCalledWith({}); + + const buttons = findPlatformButtons(); + + expect(buttons).toHaveLength(mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes.length); + }); + + it('should contain a number of dropdown items for the architecture options', () => { + expect(findArchitectureDropdownItems()).toHaveLength( + mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes[0].architectures.nodes.length, + ); + }); + + describe('should display default instructions', () => { + const { installInstructions, registerInstructions } = mockGraphqlInstructions.data.runnerSetup; + + it('runner instructions are requested', () => { + expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({ + platform: 'linux', + architecture: 'amd64', + }); + }); + + it('binary instructions are shown', () => { + const instructions = findBinaryInstructions().text(); + + expect(instructions).toBe(installInstructions); + }); + + it('register command is shown', () => { + const instructions = findRegisterCommand().text(); + + expect(instructions).toBe(registerInstructions); + }); + }); + + describe('after a platform and architecture are selected', () => { + const { + installInstructions, + registerInstructions, + } = mockGraphqlInstructionsWindows.data.runnerSetup; + + beforeEach(async () => { + runnerSetupInstructionsHandler.mockResolvedValue(mockGraphqlInstructionsWindows); + + findPlatformButtons().at(2).vm.$emit('click'); // another option, happens to be windows + await nextTick(); + + findArchitectureDropdownItems().at(1).vm.$emit('click'); // another option + await nextTick(); + }); + + it('runner instructions are requested', () => { + expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({ + platform: 'windows', + architecture: '386', + }); + }); + + it('other binary instructions are shown', () => { + const instructions = findBinaryInstructions().text(); + + expect(instructions).toBe(installInstructions); + }); + + it('register command is shown', () => { + const command = findRegisterCommand().text(); + + expect(command).toBe(registerInstructions); + }); + }); + + describe('when apollo is loading', () => { + it('should show a skeleton loader', async () => { + createComponent(); + expect(findSkeletonLoader().exists()).toBe(true); + expect(findGlLoadingIcon().exists()).toBe(false); + + await nextTick(); // wait for platforms + + expect(findGlLoadingIcon().exists()).toBe(true); + }); + + it('once loaded, should not show a loading state', async () => { + createComponent(); + + await nextTick(); // wait for platforms + await nextTick(); // wait for architectures + + expect(findSkeletonLoader().exists()).toBe(false); + expect(findGlLoadingIcon().exists()).toBe(false); + }); + }); + + describe('when instructions cannot be loaded', () => { + beforeEach(async () => { + runnerSetupInstructionsHandler.mockRejectedValue(); + + createComponent(); + + await waitForPromises(); + }); + + it('should show alert', () => { + expect(findAlert().exists()).toBe(true); + }); + + it('should not show instructions', () => { + expect(findBinaryInstructions().exists()).toBe(false); + expect(findRegisterCommand().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js index 48db60bfd33..23f8d6afcb5 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js +++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js @@ -1,113 +1,41 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import getRunnerPlatforms from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql'; -import getRunnerSetupInstructions from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue'; - -import { mockGraphqlRunnerPlatforms, mockGraphqlInstructions } from './mock_data'; - -const projectPath = 'gitlab-org/gitlab'; -const localVue = createLocalVue(); -localVue.use(VueApollo); +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; describe('RunnerInstructions component', () => { let wrapper; - let fakeApollo; - - const findModalButton = () => wrapper.find('[data-testid="show-modal-button"]'); - const findPlatformButtons = () => wrapper.findAll('[data-testid="platform-button"]'); - const findArchitectureDropdownItems = () => - wrapper.findAll('[data-testid="architecture-dropdown-item"]'); - const findBinaryInstructionsSection = () => wrapper.find('[data-testid="binary-instructions"]'); - const findRunnerInstructionsSection = () => wrapper.find('[data-testid="runner-instructions"]'); - beforeEach(async () => { - const requestHandlers = [ - [getRunnerPlatforms, jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms)], - [getRunnerSetupInstructions, jest.fn().mockResolvedValue(mockGraphqlInstructions)], - ]; + const findModalButton = () => wrapper.findByTestId('show-modal-button'); + const findModal = () => wrapper.findComponent(RunnerInstructionsModal); - fakeApollo = createMockApollo(requestHandlers); + const createComponent = () => { + wrapper = extendedWrapper(shallowMount(RunnerInstructions)); + }; - wrapper = shallowMount(RunnerInstructions, { - provide: { - projectPath, - }, - localVue, - apolloProvider: fakeApollo, - }); - - await wrapper.vm.$nextTick(); + beforeEach(() => { + createComponent(); }); afterEach(() => { wrapper.destroy(); - wrapper = null; }); it('should show the "Show Runner installation instructions" button', () => { - const button = findModalButton(); - - expect(button.exists()).toBe(true); - expect(button.text()).toBe('Show Runner installation instructions'); - }); - - it('should contain a number of platforms buttons', () => { - const buttons = findPlatformButtons(); - - expect(buttons).toHaveLength(mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes.length); - }); - - it('should contain a number of dropdown items for the architecture options', () => { - const platformButton = findPlatformButtons().at(0); - platformButton.vm.$emit('click'); - - return wrapper.vm.$nextTick(() => { - const dropdownItems = findArchitectureDropdownItems(); - - expect(dropdownItems).toHaveLength( - mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes[0].architectures.nodes.length, - ); - }); + expect(findModalButton().exists()).toBe(true); + expect(findModalButton().text()).toBe('Show Runner installation instructions'); }); - it('should display the binary installation instructions for a selected architecture', async () => { - const platformButton = findPlatformButtons().at(0); - platformButton.vm.$emit('click'); - - await wrapper.vm.$nextTick(); - - const dropdownItem = findArchitectureDropdownItems().at(0); - dropdownItem.vm.$emit('click'); - - await wrapper.vm.$nextTick(); - - const runner = findBinaryInstructionsSection(); - - expect(runner.text()).toMatch('sudo chmod +x /usr/local/bin/gitlab-runner'); - expect(runner.text()).toMatch( - `sudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash`, - ); - expect(runner.text()).toMatch( - 'sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner', - ); - expect(runner.text()).toMatch('sudo gitlab-runner start'); + it('should not render the modal once mounted', () => { + expect(findModal().exists()).toBe(false); }); - it('should display the runner register instructions for a selected architecture', async () => { - const platformButton = findPlatformButtons().at(0); - platformButton.vm.$emit('click'); - - await wrapper.vm.$nextTick(); - - const dropdownItem = findArchitectureDropdownItems().at(0); - dropdownItem.vm.$emit('click'); - - await wrapper.vm.$nextTick(); + it('should render the modal once clicked', async () => { + findModalButton().vm.$emit('click'); - const runner = findRunnerInstructionsSection(); + await nextTick(); - expect(runner.text()).toMatch(mockGraphqlInstructions.data.runnerSetup.registerInstructions); + expect(findModal().exists()).toBe(true); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/copyable_field_spec.js b/spec/frontend/vue_shared/components/sidebar/copyable_field_spec.js new file mode 100644 index 00000000000..b99b1a66b79 --- /dev/null +++ b/spec/frontend/vue_shared/components/sidebar/copyable_field_spec.js @@ -0,0 +1,74 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue'; + +describe('SidebarCopyableField', () => { + let wrapper; + + const defaultProps = { + value: 'Gl-1', + name: 'Reference', + }; + + const createComponent = (propsData = defaultProps) => { + wrapper = shallowMount(CopyableField, { + propsData, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findClipboardButton = () => wrapper.findComponent(ClipboardButton); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + describe('template', () => { + describe('when `isLoading` prop is `false`', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders copyable field', () => { + expect(wrapper.text()).toContain('Reference: Gl-1'); + }); + + it('renders ClipboardButton with correct props', () => { + const clipboardButton = findClipboardButton(); + + expect(clipboardButton.exists()).toBe(true); + expect(clipboardButton.props('title')).toBe(`Copy ${defaultProps.name}`); + expect(clipboardButton.props('text')).toBe(defaultProps.value); + }); + + it('does not render loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('when `isLoading` prop is `true`', () => { + beforeEach(() => { + createComponent({ ...defaultProps, isLoading: true }); + }); + + it('renders loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + expect(findLoadingIcon().props('label')).toBe('Loading Reference'); + }); + + it('does not render clipboard button', () => { + expect(findClipboardButton().exists()).toBe(false); + }); + }); + + describe('with `clipboardTooltipText` prop', () => { + it('sets ClipboardButton `title` prop to `clipboardTooltipText` value', () => { + const mockClipboardTooltipText = 'Copy my custom value'; + createComponent({ ...defaultProps, clipboardTooltipText: mockClipboardTooltipText }); + + expect(findClipboardButton().props('title')).toBe(mockClipboardTooltipText); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/url_sync_spec.js b/spec/frontend/vue_shared/components/url_sync_spec.js new file mode 100644 index 00000000000..86bbc146c5f --- /dev/null +++ b/spec/frontend/vue_shared/components/url_sync_spec.js @@ -0,0 +1,97 @@ +import { shallowMount } from '@vue/test-utils'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { historyPushState } from '~/lib/utils/common_utils'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import UrlSyncComponent from '~/vue_shared/components/url_sync.vue'; + +jest.mock('~/lib/utils/url_utility', () => ({ + mergeUrlParams: jest.fn((query, url) => `urlParams: ${query} ${url}`), +})); + +jest.mock('~/lib/utils/common_utils', () => ({ + historyPushState: jest.fn(), +})); + +describe('url sync component', () => { + let wrapper; + const mockQuery = { group_id: '5014437163714', project_ids: ['5014437608314'] }; + const TEST_HOST = 'http://testhost/'; + + setWindowLocation(TEST_HOST); + + const findButton = () => wrapper.find('button'); + + const createComponent = ({ query = mockQuery, scopedSlots, slots } = {}) => { + wrapper = shallowMount(UrlSyncComponent, { + propsData: { query }, + scopedSlots, + slots, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const expectUrlSync = (query, times, mergeUrlParamsReturnValue) => { + expect(mergeUrlParams).toHaveBeenCalledTimes(times); + expect(mergeUrlParams).toHaveBeenCalledWith(query, TEST_HOST, { spreadArrays: true }); + + expect(historyPushState).toHaveBeenCalledTimes(times); + expect(historyPushState).toHaveBeenCalledWith(mergeUrlParamsReturnValue); + }; + + describe('with query as a props', () => { + it('immediately syncs the query to the URL', () => { + createComponent(); + + expectUrlSync(mockQuery, 1, mergeUrlParams.mock.results[0].value); + }); + + describe('when the query is modified', () => { + const newQuery = { foo: true }; + + it('updates the URL with the new query', async () => { + createComponent(); + // using setProps to test the watcher + await wrapper.setProps({ query: newQuery }); + + expectUrlSync(mockQuery, 2, mergeUrlParams.mock.results[1].value); + }); + }); + }); + + describe('with scoped slot', () => { + const scopedSlots = { + default: ` + <button @click="props.updateQuery({bar: 'baz'})">Update Query </button> + `, + }; + + it('renders the scoped slot', () => { + createComponent({ query: null, scopedSlots }); + + expect(findButton().exists()).toBe(true); + }); + + it('syncs the url with the scoped slots function', () => { + createComponent({ query: null, scopedSlots }); + + findButton().trigger('click'); + + expectUrlSync({ bar: 'baz' }, 1, mergeUrlParams.mock.results[0].value); + }); + }); + + describe('with slot', () => { + const slots = { + default: '<button>Normal Slot</button>', + }; + + it('renders the default slot', () => { + createComponent({ query: null, slots }); + + expect(findButton().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js index 184a1e458b5..87fe8619f28 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 @@ -1,4 +1,4 @@ -import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlSprintf, GlIcon } from '@gitlab/ui'; +import { GlSkeletonLoader, GlSprintf, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { AVAILABILITY_STATUS } from '~/set_status_modal/utils'; import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue'; @@ -52,7 +52,7 @@ describe('User Popover Component', () => { }; describe('when user is loading', () => { - it('displays skeleton loaders', () => { + it('displays skeleton loader', () => { createWrapper({ user: { name: null, @@ -65,7 +65,7 @@ describe('User Popover Component', () => { }, }); - expect(wrapper.findAll(GlSkeletonLoading)).toHaveLength(4); + expect(wrapper.find(GlSkeletonLoader).exists()).toBe(true); }); }); diff --git a/spec/frontend/vue_shared/oncall_schedules_list_spec.js b/spec/frontend/vue_shared/oncall_schedules_list_spec.js new file mode 100644 index 00000000000..5c30809c09b --- /dev/null +++ b/spec/frontend/vue_shared/oncall_schedules_list_spec.js @@ -0,0 +1,87 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; + +const mockSchedules = [ + { + name: 'Schedule 1', + scheduleUrl: 'http://gitlab.com/gitlab-org/gitlab-shell/-/oncall_schedules', + projectName: 'Shell', + projectUrl: 'http://gitlab.com/gitlab-org/gitlab-shell/', + }, + { + name: 'Schedule 2', + scheduleUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/-/oncall_schedules', + projectName: 'UI', + projectUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/', + }, +]; + +const userName = 'User 1'; + +describe('On-call schedules list', () => { + let wrapper; + + function createComponent(props) { + wrapper = extendedWrapper( + shallowMount(OncallSchedulesList, { + propsData: { + schedules: mockSchedules, + userName, + ...props, + }, + stubs: { + GlSprintf, + }, + }), + ); + } + + afterEach(() => { + wrapper.destroy(); + }); + + const findLinks = () => wrapper.findAllComponents(GlLink); + const findTitle = () => wrapper.findByTestId('title'); + const findFooter = () => wrapper.findByTestId('footer'); + const findSchedules = () => wrapper.findByTestId('schedules-list'); + + describe.each` + isCurrentUser | titleText | footerText + ${true} | ${'You are currently a part of:'} | ${'Removing yourself may put your on-call team at risk of missing a notification.'} + ${false} | ${`User ${userName} is currently part of:`} | ${'Removing this user may put their on-call team at risk of missing a notification.'} + `('when current user ', ({ isCurrentUser, titleText, footerText }) => { + it(`${isCurrentUser ? 'is' : 'is not'} a part of on-call schedule`, async () => { + createComponent({ + isCurrentUser, + }); + + expect(findTitle().text()).toBe(titleText); + expect(findFooter().text()).toBe(footerText); + }); + }); + + describe.each(mockSchedules)( + 'renders each on-call schedule data', + ({ name, scheduleUrl, projectName, projectUrl }) => { + beforeEach(() => { + createComponent({ schedules: [{ name, scheduleUrl, projectName, projectUrl }] }); + }); + + it(`renders schedule ${name}'s name and link`, () => { + const msg = findSchedules().text(); + + expect(msg).toContain(`On-call schedule ${name}`); + expect(findLinks().at(0).attributes('href')).toBe(scheduleUrl); + }); + + it(`renders project ${projectName}'s name and link`, () => { + const msg = findSchedules().text(); + + expect(msg).toContain(`in Project ${projectName}`); + expect(findLinks().at(1).attributes('href')).toBe(projectUrl); + }); + }, + ); +}); diff --git a/spec/frontend/whats_new/components/app_spec.js b/spec/frontend/whats_new/components/app_spec.js index ad062d04140..45c4682208b 100644 --- a/spec/frontend/whats_new/components/app_spec.js +++ b/spec/frontend/whats_new/components/app_spec.js @@ -1,4 +1,4 @@ -import { GlDrawer, GlInfiniteScroll, GlTabs } from '@gitlab/ui'; +import { GlDrawer, GlInfiniteScroll } from '@gitlab/ui'; import { createLocalVue, mount } from '@vue/test-utils'; import Vuex from 'vuex'; import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper'; @@ -21,12 +21,9 @@ describe('App', () => { let actions; let state; let trackingSpy; - let gitlabDotCom = true; const buildProps = () => ({ - storageKey: 'storage-key', - versions: ['3.11', '3.10'], - gitlabDotCom, + versionDigest: 'version-digest', }); const buildWrapper = () => { @@ -91,7 +88,7 @@ describe('App', () => { }); it('dispatches openDrawer and tracking calls when mounted', () => { - expect(actions.openDrawer).toHaveBeenCalledWith(expect.any(Object), 'storage-key'); + expect(actions.openDrawer).toHaveBeenCalledWith(expect.any(Object), 'version-digest'); expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_whats_new_drawer', { label: 'namespace_id', value: 'namespace-840', @@ -176,54 +173,4 @@ describe('App', () => { ); }); }); - - describe('self managed', () => { - const findTabs = () => wrapper.find(GlTabs); - - const clickSecondTab = async () => { - const secondTab = wrapper.findAll('.nav-link').at(1); - await secondTab.trigger('click'); - await new Promise((resolve) => requestAnimationFrame(resolve)); - }; - - beforeEach(() => { - gitlabDotCom = false; - setup(); - }); - - it('renders tabs with drawer body height and content', () => { - const scroll = findInfiniteScroll(); - const tabs = findTabs(); - - expect(scroll.exists()).toBe(false); - expect(tabs.attributes().style).toBe(`height: ${MOCK_DRAWER_BODY_HEIGHT}px;`); - expect(wrapper.find('h5').text()).toBe('Whats New Drawer'); - }); - - describe('fetchVersion', () => { - beforeEach(() => { - actions.fetchItems.mockClear(); - }); - - it('when version isnt fetched, clicking a tab calls fetchItems', async () => { - const fetchVersionSpy = jest.spyOn(wrapper.vm, 'fetchVersion'); - await clickSecondTab(); - - expect(fetchVersionSpy).toHaveBeenCalledWith('3.10'); - expect(actions.fetchItems).toHaveBeenCalledWith(expect.anything(), { version: '3.10' }); - }); - - it('when version has been fetched, clicking a tab calls fetchItems', async () => { - wrapper.vm.$store.state.features.push({ title: 'GitLab Stories', release: 3.1 }); - await wrapper.vm.$nextTick(); - - const fetchVersionSpy = jest.spyOn(wrapper.vm, 'fetchVersion'); - await clickSecondTab(); - - expect(fetchVersionSpy).toHaveBeenCalledWith('3.10'); - expect(actions.fetchItems).not.toHaveBeenCalled(); - expect(wrapper.find('.tab-pane.active h5').text()).toBe('GitLab Stories'); - }); - }); - }); }); diff --git a/spec/frontend/whats_new/store/actions_spec.js b/spec/frontend/whats_new/store/actions_spec.js index c4125d28aba..39ad526cf14 100644 --- a/spec/frontend/whats_new/store/actions_spec.js +++ b/spec/frontend/whats_new/store/actions_spec.js @@ -11,9 +11,12 @@ describe('whats new actions', () => { useLocalStorageSpy(); it('should commit openDrawer', () => { - testAction(actions.openDrawer, 'storage-key', {}, [{ type: types.OPEN_DRAWER }]); + testAction(actions.openDrawer, 'digest-hash', {}, [{ type: types.OPEN_DRAWER }]); - expect(window.localStorage.setItem).toHaveBeenCalledWith('storage-key', 'false'); + expect(window.localStorage.setItem).toHaveBeenCalledWith( + 'display-whats-new-notification', + 'digest-hash', + ); }); }); @@ -45,12 +48,12 @@ describe('whats new actions', () => { axiosMock.reset(); axiosMock - .onGet('/-/whats_new', { params: { page: 8, version: 40 } }) + .onGet('/-/whats_new', { params: { page: 8 } }) .replyOnce(200, [{ title: 'GitLab Stories' }]); testAction( actions.fetchItems, - { page: 8, version: 40 }, + { page: 8 }, {}, expect.arrayContaining([ { type: types.ADD_FEATURES, payload: [{ title: 'GitLab Stories' }] }, diff --git a/spec/frontend/whats_new/utils/notification_spec.js b/spec/frontend/whats_new/utils/notification_spec.js index e3e390f4394..e1de65df30f 100644 --- a/spec/frontend/whats_new/utils/notification_spec.js +++ b/spec/frontend/whats_new/utils/notification_spec.js @@ -1,5 +1,5 @@ import { useLocalStorageSpy } from 'helpers/local_storage_helper'; -import { setNotification, getStorageKey } from '~/whats_new/utils/notification'; +import { setNotification, getVersionDigest } from '~/whats_new/utils/notification'; describe('~/whats_new/utils/notification', () => { useLocalStorageSpy(); @@ -33,10 +33,23 @@ describe('~/whats_new/utils/notification', () => { expect(notificationEl.classList).toContain('with-notifications'); }); - it('removes class and count element when storage key is true', () => { + it('removes class and count element when legacy storage key is false', () => { const notificationEl = findNotificationEl(); notificationEl.classList.add('with-notifications'); - localStorage.setItem('storage-key', 'false'); + localStorage.setItem('display-whats-new-notification-13.10', 'false'); + + expect(findNotificationCountEl()).toExist(); + + subject(); + + expect(findNotificationCountEl()).not.toExist(); + expect(notificationEl.classList).not.toContain('with-notifications'); + }); + + it('removes class and count element when storage key has current digest', () => { + const notificationEl = findNotificationEl(); + notificationEl.classList.add('with-notifications'); + localStorage.setItem('display-whats-new-notification', 'version-digest'); expect(findNotificationCountEl()).toExist(); @@ -47,9 +60,9 @@ describe('~/whats_new/utils/notification', () => { }); }); - describe('getStorageKey', () => { + describe('getVersionDigest', () => { it('retrieves the storage key data attribute from the el', () => { - expect(getStorageKey(getAppEl())).toBe('storage-key'); + expect(getVersionDigest(getAppEl())).toBe('version-digest'); }); }); }); diff --git a/spec/frontend/wikis_spec.js b/spec/frontend/wikis_spec.js index c4a2bf1a69a..c4e914bcf34 100644 --- a/spec/frontend/wikis_spec.js +++ b/spec/frontend/wikis_spec.js @@ -4,159 +4,6 @@ import Wikis from '~/pages/shared/wikis/wikis'; import Tracking from '~/tracking'; describe('Wikis', () => { - const editFormHtmlFixture = (args) => `<form class="wiki-form ${ - args.newPage ? 'js-new-wiki-page' : '' - }"> - <input type="text" id="wiki_title" value="My title" /> - <input type="text" id="wiki_message" /> - <select class="form-control select-control" name="wiki[format]" id="wiki_format"> - <option value="markdown">Markdown</option> - <option selected="selected" value="rdoc">RDoc</option> - <option value="asciidoc">AsciiDoc</option> - <option value="org">Org</option> - </select> - <textarea id="wiki_content"></textarea> - <code class="js-markup-link-example">{Link title}[link:page-slug]</code> - <input type="submit" class="js-wiki-btn-submit"> - </input> - </form> - `; - - let wikis; - let titleInput; - let contentInput; - let messageInput; - let changeFormatSelect; - let linkExample; - - const findBeforeUnloadWarning = () => window.onbeforeunload?.(); - const findForm = () => document.querySelector('.wiki-form'); - const findSubmitButton = () => document.querySelector('.js-wiki-btn-submit'); - - describe('when the wiki page is being created', () => { - const formHtmlFixture = editFormHtmlFixture({ newPage: true }); - - beforeEach(() => { - setHTMLFixture(formHtmlFixture); - - titleInput = document.getElementById('wiki_title'); - messageInput = document.getElementById('wiki_message'); - changeFormatSelect = document.querySelector('#wiki_format'); - linkExample = document.querySelector('.js-markup-link-example'); - wikis = new Wikis(); - }); - - it('binds an event listener to the title input', () => { - wikis.handleWikiTitleChange = jest.fn(); - - titleInput.dispatchEvent(new Event('keyup')); - - expect(wikis.handleWikiTitleChange).toHaveBeenCalled(); - }); - - it('sets the commit message when title changes', () => { - titleInput.value = 'My title'; - messageInput.value = ''; - - titleInput.dispatchEvent(new Event('keyup')); - - expect(messageInput.value).toEqual('Create My title'); - }); - - it('replaces hyphens with spaces', () => { - titleInput.value = 'my-hyphenated-title'; - titleInput.dispatchEvent(new Event('keyup')); - - expect(messageInput.value).toEqual('Create my hyphenated title'); - }); - }); - - describe('when the wiki page is being updated', () => { - const formHtmlFixture = editFormHtmlFixture({ newPage: false }); - - beforeEach(() => { - setHTMLFixture(formHtmlFixture); - - titleInput = document.getElementById('wiki_title'); - messageInput = document.getElementById('wiki_message'); - wikis = new Wikis(); - }); - - it('sets the commit message when title changes, prefixing with "Update"', () => { - titleInput.value = 'My title'; - messageInput.value = ''; - - titleInput.dispatchEvent(new Event('keyup')); - - expect(messageInput.value).toEqual('Update My title'); - }); - - it.each` - value | text - ${'markdown'} | ${'[Link Title](page-slug)'} - ${'rdoc'} | ${'{Link title}[link:page-slug]'} - ${'asciidoc'} | ${'link:page-slug[Link title]'} - ${'org'} | ${'[[page-slug]]'} - `('updates a message when value=$value is selected', ({ value, text }) => { - changeFormatSelect.value = value; - changeFormatSelect.dispatchEvent(new Event('change')); - - expect(linkExample.innerHTML).toBe(text); - }); - - it('starts with no unload warning', () => { - expect(findBeforeUnloadWarning()).toBeUndefined(); - }); - - describe('when wiki content is updated', () => { - beforeEach(() => { - contentInput = document.getElementById('wiki_content'); - contentInput.value = 'Lorem ipsum dolar sit!'; - contentInput.dispatchEvent(new Event('input')); - }); - - it('sets before unload warning', () => { - expect(findBeforeUnloadWarning()).toBe(''); - }); - - it('when form submitted, unsets before unload warning', () => { - findForm().dispatchEvent(new Event('submit')); - expect(findBeforeUnloadWarning()).toBeUndefined(); - }); - }); - }); - - describe('submit button state', () => { - beforeEach(() => { - setHTMLFixture(editFormHtmlFixture({ newPage: true })); - - titleInput = document.getElementById('wiki_title'); - contentInput = document.getElementById('wiki_content'); - - wikis = new Wikis(); - }); - - it.each` - title | text | buttonState | disabledAttr - ${'something'} | ${'something'} | ${'enabled'} | ${null} - ${''} | ${'something'} | ${'disabled'} | ${'true'} - ${'something'} | ${''} | ${'disabled'} | ${'true'} - ${''} | ${''} | ${'disabled'} | ${'true'} - ${' '} | ${' '} | ${'disabled'} | ${'true'} - `( - "when title='$title', content='$content', then, buttonState='$buttonState'", - ({ title, text, disabledAttr }) => { - titleInput.value = title; - titleInput.dispatchEvent(new Event('keyup')); - - contentInput.value = text; - contentInput.dispatchEvent(new Event('input')); - - expect(findSubmitButton().getAttribute('disabled')).toBe(disabledAttr); - }, - ); - }); - describe('trackPageView', () => { const trackingPage = 'projects:wikis:show'; const trackingContext = { foo: 'bar' }; |