diff options
Diffstat (limited to 'spec/frontend')
289 files changed, 6837 insertions, 2959 deletions
diff --git a/spec/frontend/__helpers__/matchers.js b/spec/frontend/__helpers__/matchers.js deleted file mode 100644 index 945abdafe9a..00000000000 --- a/spec/frontend/__helpers__/matchers.js +++ /dev/null @@ -1,68 +0,0 @@ -export default { - toHaveSpriteIcon: (element, iconName) => { - if (!iconName) { - throw new Error('toHaveSpriteIcon is missing iconName argument!'); - } - - if (!(element instanceof HTMLElement)) { - throw new Error(`${element} is not a DOM element!`); - } - - const iconReferences = [].slice.apply(element.querySelectorAll('svg use')); - const matchingIcon = iconReferences.find( - (reference) => reference.parentNode.getAttribute('data-testid') === `${iconName}-icon`, - ); - - const pass = Boolean(matchingIcon); - - let message; - if (pass) { - message = `${element.outerHTML} contains the sprite icon "${iconName}"!`; - } else { - message = `${element.outerHTML} does not contain the sprite icon "${iconName}"!`; - - const existingIcons = iconReferences.map((reference) => { - const iconUrl = reference.getAttribute('href'); - return `"${iconUrl.replace(/^.+#/, '')}"`; - }); - if (existingIcons.length > 0) { - message += ` (only found ${existingIcons.join(',')})`; - } - } - - return { - pass, - message: () => message, - }; - }, - toMatchInterpolatedText(received, match) { - let clearReceived; - let clearMatch; - - try { - clearReceived = received.replace(/\s\s+/gm, ' ').replace(/\s\./gm, '.').trim(); - } catch (e) { - return { actual: received, message: 'The received value is not a string', pass: false }; - } - try { - clearMatch = match.replace(/%{\w+}/gm, '').trim(); - } catch (e) { - return { message: 'The comparator value is not a string', pass: false }; - } - const pass = clearReceived === clearMatch; - const message = pass - ? () => ` - \n\n - Expected: ${this.utils.printExpected(clearReceived)} - To not equal: ${this.utils.printReceived(clearMatch)} - ` - : () => - ` - \n\n - Expected: ${this.utils.printExpected(clearReceived)} - To equal: ${this.utils.printReceived(clearMatch)} - `; - - return { actual: received, message, pass }; - }, -}; diff --git a/spec/frontend/__helpers__/matchers/index.js b/spec/frontend/__helpers__/matchers/index.js new file mode 100644 index 00000000000..76571bafb06 --- /dev/null +++ b/spec/frontend/__helpers__/matchers/index.js @@ -0,0 +1,3 @@ +export * from './to_have_sprite_icon'; +export * from './to_have_tracking_attributes'; +export * from './to_match_interpolated_text'; diff --git a/spec/frontend/__helpers__/matchers/to_have_sprite_icon.js b/spec/frontend/__helpers__/matchers/to_have_sprite_icon.js new file mode 100644 index 00000000000..bce9d93bea8 --- /dev/null +++ b/spec/frontend/__helpers__/matchers/to_have_sprite_icon.js @@ -0,0 +1,36 @@ +export const toHaveSpriteIcon = (element, iconName) => { + if (!iconName) { + throw new Error('toHaveSpriteIcon is missing iconName argument!'); + } + + if (!(element instanceof HTMLElement)) { + throw new Error(`${element} is not a DOM element!`); + } + + const iconReferences = [].slice.apply(element.querySelectorAll('svg use')); + const matchingIcon = iconReferences.find( + (reference) => reference.parentNode.getAttribute('data-testid') === `${iconName}-icon`, + ); + + const pass = Boolean(matchingIcon); + + let message; + if (pass) { + message = `${element.outerHTML} contains the sprite icon "${iconName}"!`; + } else { + message = `${element.outerHTML} does not contain the sprite icon "${iconName}"!`; + + const existingIcons = iconReferences.map((reference) => { + const iconUrl = reference.getAttribute('href'); + return `"${iconUrl.replace(/^.+#/, '')}"`; + }); + if (existingIcons.length > 0) { + message += ` (only found ${existingIcons.join(',')})`; + } + } + + return { + pass, + message: () => message, + }; +}; diff --git a/spec/frontend/__helpers__/matchers/to_have_tracking_attributes.js b/spec/frontend/__helpers__/matchers/to_have_tracking_attributes.js new file mode 100644 index 00000000000..fd3f3aa042f --- /dev/null +++ b/spec/frontend/__helpers__/matchers/to_have_tracking_attributes.js @@ -0,0 +1,35 @@ +import { diff } from 'jest-diff'; +import { isObject, mapValues, isEqual } from 'lodash'; + +export const toHaveTrackingAttributes = (actual, obj) => { + if (!(actual instanceof Element)) { + return { actual, message: () => 'The received value must be an Element.', pass: false }; + } + + if (!isObject(obj)) { + return { + message: () => `The matching object must be an object. Found ${obj}.`, + pass: false, + }; + } + + const actualAttributes = mapValues(obj, (val, key) => actual.getAttribute(`data-track-${key}`)); + + const matcherPass = isEqual(actualAttributes, obj); + + const failMessage = () => { + // We can match, but still fail because we're in a `expect...not.` context + if (matcherPass) { + return `Expected the element's tracking attributes not to match. Found that they matched ${JSON.stringify( + obj, + )}.`; + } + + const objDiff = diff(actualAttributes, obj); + return `Expected the element's tracking attributes to match the given object. Diff: +${objDiff} +`; + }; + + return { actual, message: failMessage, pass: matcherPass }; +}; diff --git a/spec/frontend/__helpers__/matchers/to_have_tracking_attributes_spec.js b/spec/frontend/__helpers__/matchers/to_have_tracking_attributes_spec.js new file mode 100644 index 00000000000..74073ed4063 --- /dev/null +++ b/spec/frontend/__helpers__/matchers/to_have_tracking_attributes_spec.js @@ -0,0 +1,65 @@ +import { diff } from 'jest-diff'; + +describe('custom matcher toHaveTrackingAttributes', () => { + const createElementWithAttrs = (attributes) => { + const el = document.createElement('div'); + + Object.entries(attributes).forEach(([key, value]) => { + el.setAttribute(key, value); + }); + + return el; + }; + + it('blows up if actual is not an element', () => { + expect(() => { + expect({}).toHaveTrackingAttributes({}); + }).toThrow('The received value must be an Element.'); + }); + + it('blows up if expected is not an object', () => { + expect(() => { + expect(createElementWithAttrs({})).toHaveTrackingAttributes('foo'); + }).toThrow('The matching object must be an object.'); + }); + + it('prints diff when fails', () => { + const expectedDiff = diff({ label: 'foo' }, { label: 'a' }); + expect(() => { + expect(createElementWithAttrs({ 'data-track-label': 'foo' })).toHaveTrackingAttributes({ + label: 'a', + }); + }).toThrow( + `Expected the element's tracking attributes to match the given object. Diff:\n${expectedDiff}\n`, + ); + }); + + describe('positive assertions', () => { + it.each` + attrs | expected + ${{ 'data-track-label': 'foo' }} | ${{ label: 'foo' }} + ${{ 'data-track-label': 'foo' }} | ${{}} + ${{ 'data-track-label': 'foo', label: 'bar' }} | ${{ label: 'foo' }} + ${{ 'data-track-label': 'foo', 'data-track-extra': '123' }} | ${{ label: 'foo', extra: '123' }} + ${{ 'data-track-label': 'foo', 'data-track-extra': '123' }} | ${{ extra: '123' }} + ${{ label: 'foo', extra: '123', id: '7' }} | ${{}} + `('$expected matches element with attrs $attrs', ({ attrs, expected }) => { + expect(createElementWithAttrs(attrs)).toHaveTrackingAttributes(expected); + }); + }); + + describe('negative assertions', () => { + it.each` + attrs | expected + ${{}} | ${{ label: 'foo' }} + ${{ label: 'foo' }} | ${{ label: 'foo' }} + ${{ 'data-track-label': 'bar', label: 'foo' }} | ${{ label: 'foo' }} + ${{ 'data-track-label': 'foo' }} | ${{ extra: '123' }} + ${{ 'data-track-label': 'foo', 'data-track-extra': '123' }} | ${{ label: 'foo', extra: '456' }} + ${{ 'data-track-label': 'foo', 'data-track-extra': '123' }} | ${{ label: 'foo', extra: '123', action: 'click' }} + ${{ label: 'foo', extra: '123', id: '7' }} | ${{ id: '7' }} + `('$expected does not match element with attrs $attrs', ({ attrs, expected }) => { + expect(createElementWithAttrs(attrs)).not.toHaveTrackingAttributes(expected); + }); + }); +}); diff --git a/spec/frontend/__helpers__/matchers/to_match_interpolated_text.js b/spec/frontend/__helpers__/matchers/to_match_interpolated_text.js new file mode 100644 index 00000000000..4ce814a01b4 --- /dev/null +++ b/spec/frontend/__helpers__/matchers/to_match_interpolated_text.js @@ -0,0 +1,30 @@ +export const toMatchInterpolatedText = (received, match) => { + let clearReceived; + let clearMatch; + + try { + clearReceived = received.replace(/\s\s+/gm, ' ').replace(/\s\./gm, '.').trim(); + } catch (e) { + return { actual: received, message: 'The received value is not a string', pass: false }; + } + try { + clearMatch = match.replace(/%{\w+}/gm, '').trim(); + } catch (e) { + return { message: 'The comparator value is not a string', pass: false }; + } + const pass = clearReceived === clearMatch; + const message = pass + ? () => ` + \n\n + Expected: ${this.utils.printExpected(clearReceived)} + To not equal: ${this.utils.printReceived(clearMatch)} + ` + : () => + ` + \n\n + Expected: ${this.utils.printExpected(clearReceived)} + To equal: ${this.utils.printReceived(clearMatch)} + `; + + return { actual: received, message, pass }; +}; diff --git a/spec/frontend/__helpers__/matchers/to_match_interpolated_text_spec.js b/spec/frontend/__helpers__/matchers/to_match_interpolated_text_spec.js new file mode 100644 index 00000000000..f6fd00011fe --- /dev/null +++ b/spec/frontend/__helpers__/matchers/to_match_interpolated_text_spec.js @@ -0,0 +1,46 @@ +describe('custom matcher toMatchInterpolatedText', () => { + describe('malformed input', () => { + it.each([null, 1, Symbol, Array, Object])( + 'fails graciously if the expected value is %s', + (expected) => { + expect(expected).not.toMatchInterpolatedText('null'); + }, + ); + }); + describe('malformed matcher', () => { + it.each([null, 1, Symbol, Array, Object])( + 'fails graciously if the matcher is %s', + (matcher) => { + expect('null').not.toMatchInterpolatedText(matcher); + }, + ); + }); + + describe('positive assertion', () => { + it.each` + htmlString | templateString + ${'foo'} | ${'foo'} + ${'foo'} | ${'foo%{foo}'} + ${'foo '} | ${'foo'} + ${'foo '} | ${'foo%{foo}'} + ${'foo . '} | ${'foo%{foo}.'} + ${'foo bar . '} | ${'foo%{foo} bar.'} + ${'foo\n\nbar . '} | ${'foo%{foo} bar.'} + ${'foo bar . .'} | ${'foo%{fooStart} bar.%{fooEnd}.'} + `('$htmlString equals $templateString', ({ htmlString, templateString }) => { + expect(htmlString).toMatchInterpolatedText(templateString); + }); + }); + + describe('negative assertion', () => { + it.each` + htmlString | templateString + ${'foo'} | ${'bar'} + ${'foo'} | ${'bar%{foo}'} + ${'foo'} | ${'@{lol}foo%{foo}'} + ${' fo o '} | ${'foo'} + `('$htmlString does not equal $templateString', ({ htmlString, templateString }) => { + expect(htmlString).not.toMatchInterpolatedText(templateString); + }); + }); +}); diff --git a/spec/frontend/__helpers__/matchers_spec.js b/spec/frontend/__helpers__/matchers_spec.js deleted file mode 100644 index dfd6f754c72..00000000000 --- a/spec/frontend/__helpers__/matchers_spec.js +++ /dev/null @@ -1,48 +0,0 @@ -describe('Custom jest matchers', () => { - describe('toMatchInterpolatedText', () => { - describe('malformed input', () => { - it.each([null, 1, Symbol, Array, Object])( - 'fails graciously if the expected value is %s', - (expected) => { - expect(expected).not.toMatchInterpolatedText('null'); - }, - ); - }); - describe('malformed matcher', () => { - it.each([null, 1, Symbol, Array, Object])( - 'fails graciously if the matcher is %s', - (matcher) => { - expect('null').not.toMatchInterpolatedText(matcher); - }, - ); - }); - - describe('positive assertion', () => { - it.each` - htmlString | templateString - ${'foo'} | ${'foo'} - ${'foo'} | ${'foo%{foo}'} - ${'foo '} | ${'foo'} - ${'foo '} | ${'foo%{foo}'} - ${'foo . '} | ${'foo%{foo}.'} - ${'foo bar . '} | ${'foo%{foo} bar.'} - ${'foo\n\nbar . '} | ${'foo%{foo} bar.'} - ${'foo bar . .'} | ${'foo%{fooStart} bar.%{fooEnd}.'} - `('$htmlString equals $templateString', ({ htmlString, templateString }) => { - expect(htmlString).toMatchInterpolatedText(templateString); - }); - }); - - describe('negative assertion', () => { - it.each` - htmlString | templateString - ${'foo'} | ${'bar'} - ${'foo'} | ${'bar%{foo}'} - ${'foo'} | ${'@{lol}foo%{foo}'} - ${' fo o '} | ${'foo'} - `('$htmlString does not equal $templateString', ({ htmlString, templateString }) => { - expect(htmlString).not.toMatchInterpolatedText(templateString); - }); - }); - }); -}); diff --git a/spec/frontend/__helpers__/shared_test_setup.js b/spec/frontend/__helpers__/shared_test_setup.js index 03389e16b65..7b5df18ee0f 100644 --- a/spec/frontend/__helpers__/shared_test_setup.js +++ b/spec/frontend/__helpers__/shared_test_setup.js @@ -8,7 +8,7 @@ import setWindowLocation from './set_window_location_helper'; import { setGlobalDateToFakeDate } from './fake_date'; import { loadHTMLFixture, setHTMLFixture } from './fixtures'; import { TEST_HOST } from './test_constants'; -import customMatchers from './matchers'; +import * as customMatchers from './matchers'; import './dom_shims'; import './jquery'; diff --git a/spec/frontend/__helpers__/wait_using_real_timer.js b/spec/frontend/__helpers__/wait_using_real_timer.js deleted file mode 100644 index 110d5f46c08..00000000000 --- a/spec/frontend/__helpers__/wait_using_real_timer.js +++ /dev/null @@ -1,7 +0,0 @@ -/* useful for timing promises when jest fakeTimers are not reliable enough */ -export default (timeout) => - new Promise((resolve) => { - jest.useRealTimers(); - setTimeout(resolve, timeout); - jest.useFakeTimers(); - }); 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 bdc1dde7d48..018303fcae7 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js @@ -319,6 +319,8 @@ describe('AlertsSettingsForm', () => { const validPayloadMsg = payload === emptySamplePayload ? 'not valid' : 'valid'; it(`textarea should be ${enabledState} when payload reset ${payloadResetMsg} and payload is ${validPayloadMsg}`, async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentIntegration: { payloadExample: payload }, resetPayloadAndMappingConfirmed, @@ -345,6 +347,8 @@ describe('AlertsSettingsForm', () => { : 'was not confirmed'; it(`shows ${caption} button when sample payload ${samplePayloadMsg} and payload reset ${payloadResetMsg}`, async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentIntegration: { payloadExample, @@ -359,6 +363,8 @@ describe('AlertsSettingsForm', () => { describe('Parsing payload', () => { beforeEach(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ resetPayloadAndMappingConfirmed: true, }); @@ -456,6 +462,8 @@ describe('AlertsSettingsForm', () => { }); it('should be able to submit when form is dirty', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentIntegration: { type: typeSet.http, name: 'Existing integration' }, }); @@ -466,6 +474,8 @@ describe('AlertsSettingsForm', () => { }); it('should not be able to submit when form is pristine', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentIntegration: { type: typeSet.http, name: 'Existing integration' }, }); diff --git a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js index 5d681c7da4f..28d7ebe28df 100644 --- a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js +++ b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js @@ -126,6 +126,8 @@ describe('ProjectsDropdownFilter component', () => { }); it('applies the correct queryParams when making an api call', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchTerm: 'gitlab' }); expect(spyQuery).toHaveBeenCalledTimes(1); @@ -204,6 +206,8 @@ describe('ProjectsDropdownFilter component', () => { await createWithMockDropdown({ multiSelect: true }); selectDropdownItemAtIndex(0); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchTerm: 'this is a very long search string' }); }); diff --git a/spec/frontend/api/packages_api_spec.js b/spec/frontend/api/packages_api_spec.js index 3286dccb1b2..d55d2036dcf 100644 --- a/spec/frontend/api/packages_api_spec.js +++ b/spec/frontend/api/packages_api_spec.js @@ -38,12 +38,17 @@ describe('Api', () => { mock.onPut(expectedUrl).replyOnce(httpStatus.OK, apiResponse); return publishPackage( - { projectPath, name, version: 0, fileName: name, files: [{}] }, + { + projectPath, + name, + version: 0, + fileName: name, + files: [new File(['zip contents'], 'bar.zip')], + }, { status: 'hidden', select: 'package_file' }, ).then(({ data }) => { expect(data).toEqual(apiResponse); - expect(axios.put).toHaveBeenCalledWith(expectedUrl, expect.any(FormData), { - headers: { 'Content-Type': 'multipart/form-data' }, + expect(axios.put).toHaveBeenCalledWith(expectedUrl, expect.any(File), { params: { select: 'package_file', status: 'hidden' }, }); }); diff --git a/spec/frontend/behaviors/copy_to_clipboard_spec.js b/spec/frontend/behaviors/copy_to_clipboard_spec.js new file mode 100644 index 00000000000..c5beaa0ba5d --- /dev/null +++ b/spec/frontend/behaviors/copy_to_clipboard_spec.js @@ -0,0 +1,187 @@ +import initCopyToClipboard, { + CLIPBOARD_SUCCESS_EVENT, + CLIPBOARD_ERROR_EVENT, + I18N_ERROR_MESSAGE, +} from '~/behaviors/copy_to_clipboard'; +import { show, hide, fixTitle, once } from '~/tooltips'; + +let onceCallback = () => {}; +jest.mock('~/tooltips', () => ({ + show: jest.fn(), + hide: jest.fn(), + fixTitle: jest.fn(), + once: jest.fn((event, callback) => { + onceCallback = callback; + }), +})); + +describe('initCopyToClipboard', () => { + let clearSelection; + let focusSpy; + let dispatchEventSpy; + let button; + let clipboardInstance; + + afterEach(() => { + document.body.innerHTML = ''; + clipboardInstance = null; + }); + + const title = 'Copy this value'; + const defaultButtonAttributes = { + 'data-clipboard-text': 'foo bar', + title, + 'data-title': title, + }; + const createButton = (attributes = {}) => { + const combinedAttributes = { ...defaultButtonAttributes, ...attributes }; + button = document.createElement('button'); + Object.keys(combinedAttributes).forEach((attributeName) => { + button.setAttribute(attributeName, combinedAttributes[attributeName]); + }); + document.body.appendChild(button); + }; + + const init = () => { + clipboardInstance = initCopyToClipboard(); + }; + + const setupSpies = () => { + clearSelection = jest.fn(); + focusSpy = jest.spyOn(button, 'focus'); + dispatchEventSpy = jest.spyOn(button, 'dispatchEvent'); + }; + + const emitSuccessEvent = () => { + clipboardInstance.emit('success', { + action: 'copy', + text: 'foo bar', + trigger: button, + clearSelection, + }); + }; + + const emitErrorEvent = () => { + clipboardInstance.emit('error', { + action: 'copy', + text: 'foo bar', + trigger: button, + clearSelection, + }); + }; + + const itHandlesTooltip = (expectedTooltip) => { + it('handles tooltip', () => { + expect(button.getAttribute('title')).toBe(expectedTooltip); + expect(button.getAttribute('aria-label')).toBe(expectedTooltip); + expect(fixTitle).toHaveBeenCalledWith(button); + expect(show).toHaveBeenCalledWith(button); + expect(once).toHaveBeenCalledWith('hidden', expect.any(Function)); + + expect(hide).not.toHaveBeenCalled(); + jest.runAllTimers(); + expect(hide).toHaveBeenCalled(); + + onceCallback({ target: button }); + expect(button.getAttribute('title')).toBe(title); + expect(button.getAttribute('aria-label')).toBe(title); + expect(fixTitle).toHaveBeenCalledWith(button); + }); + }; + + describe('when value is successfully copied', () => { + it(`calls clearSelection, focuses the button, and dispatches ${CLIPBOARD_SUCCESS_EVENT} event`, () => { + createButton(); + init(); + setupSpies(); + emitSuccessEvent(); + + expect(clearSelection).toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + expect(dispatchEventSpy).toHaveBeenCalledWith(new Event(CLIPBOARD_SUCCESS_EVENT)); + }); + + describe('when `data-clipboard-handle-tooltip` is set to `false`', () => { + beforeEach(() => { + createButton({ + 'data-clipboard-handle-tooltip': 'false', + }); + init(); + emitSuccessEvent(); + }); + + it('does not handle success tooltip', () => { + expect(show).not.toHaveBeenCalled(); + }); + }); + + describe('when `data-clipboard-handle-tooltip` is set to `true`', () => { + beforeEach(() => { + createButton({ + 'data-clipboard-handle-tooltip': 'true', + }); + init(); + emitSuccessEvent(); + }); + + itHandlesTooltip('Copied'); + }); + + describe('when `data-clipboard-handle-tooltip` is not set', () => { + beforeEach(() => { + createButton(); + init(); + emitSuccessEvent(); + }); + + itHandlesTooltip('Copied'); + }); + }); + + describe('when there is an error copying the value', () => { + it(`dispatches ${CLIPBOARD_ERROR_EVENT} event`, () => { + createButton(); + init(); + setupSpies(); + emitErrorEvent(); + + expect(dispatchEventSpy).toHaveBeenCalledWith(new Event(CLIPBOARD_ERROR_EVENT)); + }); + + describe('when `data-clipboard-handle-tooltip` is set to `false`', () => { + beforeEach(() => { + createButton({ + 'data-clipboard-handle-tooltip': 'false', + }); + init(); + emitErrorEvent(); + }); + + it('does not handle error tooltip', () => { + expect(show).not.toHaveBeenCalled(); + }); + }); + + describe('when `data-clipboard-handle-tooltip` is set to `true`', () => { + beforeEach(() => { + createButton({ + 'data-clipboard-handle-tooltip': 'true', + }); + init(); + emitErrorEvent(); + }); + + itHandlesTooltip(I18N_ERROR_MESSAGE); + }); + + describe('when `data-clipboard-handle-tooltip` is not set', () => { + beforeEach(() => { + createButton(); + init(); + emitErrorEvent(); + }); + + itHandlesTooltip(I18N_ERROR_MESSAGE); + }); + }); +}); diff --git a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap index 46a5631b028..d698ee72ea4 100644 --- a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap +++ b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap @@ -20,12 +20,6 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = ` foo/bar/dummy.md </strong> - <small - class="mr-2" - > - a lot - </small> - <clipboard-button-stub category="tertiary" cssclass="btn-clipboard btn-transparent lh-100 position-static" @@ -36,5 +30,13 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = ` tooltipplacement="top" variant="default" /> + + <small + class="mr-2" + > + a lot + </small> + + <!----> </div> `; diff --git a/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap index db9684239a1..22bec77276b 100644 --- a/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap +++ b/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap @@ -17,7 +17,7 @@ exports[`Blob Header Default Actions rendering matches the snapshot 1`] = ` </div> <div - class="gl-display-none gl-sm-display-flex" + class="gl-sm-display-flex file-actions" > <viewer-switcher-stub value="simple" diff --git a/spec/frontend/blob/components/blob_edit_header_spec.js b/spec/frontend/blob/components/blob_edit_header_spec.js index ac3080c65a5..910fc5c946d 100644 --- a/spec/frontend/blob/components/blob_edit_header_spec.js +++ b/spec/frontend/blob/components/blob_edit_header_spec.js @@ -44,6 +44,8 @@ describe('Blob Header Editing', () => { const inputComponent = wrapper.find(GlFormInput); const newValue = 'bar.txt'; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ name: newValue, }); diff --git a/spec/frontend/blob/components/blob_header_filepath_spec.js b/spec/frontend/blob/components/blob_header_filepath_spec.js index d935f73c0d1..8220b598ff6 100644 --- a/spec/frontend/blob/components/blob_header_filepath_spec.js +++ b/spec/frontend/blob/components/blob_header_filepath_spec.js @@ -1,3 +1,4 @@ +import { GlBadge } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import BlobHeaderFilepath from '~/blob/components/blob_header_filepath.vue'; import { numberToHumanSize } from '~/lib/utils/number_utils'; @@ -24,6 +25,8 @@ describe('Blob Header Filepath', () => { wrapper.destroy(); }); + const findBadge = () => wrapper.find(GlBadge); + describe('rendering', () => { it('matches the snapshot', () => { createComponent(); @@ -54,6 +57,11 @@ describe('Blob Header Filepath', () => { expect(wrapper.vm.blobSize).toBe('a lot'); }); + it('renders LFS badge if LFS if enabled', () => { + createComponent({ storedExternally: true, externalStorage: 'lfs' }); + expect(findBadge().text()).toBe('LFS'); + }); + it('renders a slot and prepends its contents to the existing one', () => { const slotContent = 'Foo Bar'; createComponent( diff --git a/spec/frontend/line_highlighter_spec.js b/spec/frontend/blob/line_highlighter_spec.js index 97ae6c0e3b7..330f1f3137e 100644 --- a/spec/frontend/line_highlighter_spec.js +++ b/spec/frontend/blob/line_highlighter_spec.js @@ -1,8 +1,8 @@ /* eslint-disable no-return-assign, no-new, no-underscore-dangle */ import $ from 'jquery'; +import LineHighlighter from '~/blob/line_highlighter'; import * as utils from '~/lib/utils/common_utils'; -import LineHighlighter from '~/line_highlighter'; describe('LineHighlighter', () => { const testContext = {}; diff --git a/spec/frontend/blob/viewer/index_spec.js b/spec/frontend/blob/viewer/index_spec.js index 061ac7ad167..9e9f866d40c 100644 --- a/spec/frontend/blob/viewer/index_spec.js +++ b/spec/frontend/blob/viewer/index_spec.js @@ -21,6 +21,7 @@ describe('Blob viewer', () => { setTestTimeout(2000); beforeEach(() => { + window.gon.features = { refactorBlobViewer: false }; // This file is based on the old (non-refactored) blob viewer jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately); $.fn.extend(jQueryMock); mock = new MockAdapter(axios); diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js index 5742dfdc5d2..3af173aa18c 100644 --- a/spec/frontend/boards/components/board_card_spec.js +++ b/spec/frontend/boards/components/board_card_spec.js @@ -167,7 +167,7 @@ describe('Board card', () => { mountComponent({ item: { ...mockIssue, isLoading: true } }); expect(wrapper.classes()).toContain('is-disabled'); - expect(wrapper.classes()).not.toContain('user-can-drag'); + expect(wrapper.classes()).not.toContain('gl-cursor-grab'); }); }); @@ -177,7 +177,7 @@ describe('Board card', () => { mountComponent(); expect(wrapper.classes()).not.toContain('is-disabled'); - expect(wrapper.classes()).toContain('user-can-drag'); + expect(wrapper.classes()).toContain('gl-cursor-grab'); }); }); }); diff --git a/spec/frontend/boards/components/board_content_sidebar_spec.js b/spec/frontend/boards/components/board_content_sidebar_spec.js index 7b176cea2a3..368c7d561f8 100644 --- a/spec/frontend/boards/components/board_content_sidebar_spec.js +++ b/spec/frontend/boards/components/board_content_sidebar_spec.js @@ -9,6 +9,7 @@ import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { ISSUABLE } from '~/boards/constants'; import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; +import SidebarSeverity from '~/sidebar/components/severity/sidebar_severity.vue'; import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import SidebarLabelsWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; @@ -96,7 +97,7 @@ describe('BoardContentSidebar', () => { }); it('confirms we render MountingPortal', () => { - expect(wrapper.find(MountingPortal).props()).toMatchObject({ + expect(wrapper.findComponent(MountingPortal).props()).toMatchObject({ mountTo: '#js-right-sidebar-portal', append: true, name: 'board-content-sidebar', @@ -141,6 +142,10 @@ describe('BoardContentSidebar', () => { ); }); + it('does not render SidebarSeverity', () => { + expect(wrapper.findComponent(SidebarSeverity).exists()).toBe(false); + }); + describe('when we emit close', () => { let toggleBoardItem; @@ -160,4 +165,17 @@ describe('BoardContentSidebar', () => { }); }); }); + + describe('incident sidebar', () => { + beforeEach(() => { + createStore({ + mockGetters: { activeBoardItem: () => ({ ...mockIssue, epic: null, type: 'INCIDENT' }) }, + }); + createComponent(); + }); + + it('renders SidebarSeverity', () => { + expect(wrapper.findComponent(SidebarSeverity).exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js index ea551e94f2f..a8398a138ba 100644 --- a/spec/frontend/boards/components/board_filtered_search_spec.js +++ b/spec/frontend/boards/components/board_filtered_search_spec.js @@ -118,6 +118,7 @@ describe('BoardFilteredSearch', () => { it('sets the url params to the correct results', async () => { const mockFilters = [ { type: 'author', value: { data: 'root', operator: '=' } }, + { type: 'assignee', value: { data: 'root', operator: '=' } }, { type: 'label', value: { data: 'label', operator: '=' } }, { type: 'label', value: { data: 'label2', operator: '=' } }, { type: 'milestone', value: { data: 'New Milestone', operator: '=' } }, @@ -133,7 +134,26 @@ describe('BoardFilteredSearch', () => { title: '', replace: true, url: - 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label2&milestone_title=New+Milestone&iteration_id=3341&types=INCIDENT&weight=2&release_tag=v1.0.0', + 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label2&assignee_username=root&milestone_title=New+Milestone&iteration_id=3341&types=INCIDENT&weight=2&release_tag=v1.0.0', + }); + }); + + describe('when assignee is passed a wildcard value', () => { + const url = (arg) => `http://test.host/?assignee_id=${arg}`; + + it.each([ + ['None', url('None')], + ['Any', url('Any')], + ])('sets the url param %s', (assigneeParam, expected) => { + const mockFilters = [{ type: 'assignee', value: { data: assigneeParam, operator: '=' } }]; + jest.spyOn(urlUtility, 'updateHistory'); + findFilteredSearch().vm.$emit('onFilter', mockFilters); + + expect(urlUtility.updateHistory).toHaveBeenCalledWith({ + title: '', + replace: true, + url: expected, + }); }); }); }); diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js index 148d0c5684d..8cc0ad5f30c 100644 --- a/spec/frontend/boards/components/board_list_header_spec.js +++ b/spec/frontend/boards/components/board_list_header_spec.js @@ -180,18 +180,18 @@ describe('Board List Header Component', () => { const canDragList = [ListType.label, ListType.milestone, ListType.iteration, ListType.assignee]; it.each(cannotDragList)( - 'does not have user-can-drag-class so user cannot drag list', + 'does not have gl-cursor-grab class so user cannot drag list', (listType) => { createComponent({ listType }); - expect(findTitle().classes()).not.toContain('user-can-drag'); + expect(findTitle().classes()).not.toContain('gl-cursor-grab'); }, ); - it.each(canDragList)('has user-can-drag-class so user can drag list', (listType) => { + it.each(canDragList)('has gl-cursor-grab class so user can drag list', (listType) => { createComponent({ listType }); - expect(findTitle().classes()).toContain('user-can-drag'); + expect(findTitle().classes()).toContain('gl-cursor-grab'); }); }); }); diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js index c841c17a029..9cf7c5774bf 100644 --- a/spec/frontend/boards/components/boards_selector_spec.js +++ b/spec/frontend/boards/components/boards_selector_spec.js @@ -96,6 +96,8 @@ describe('BoardsSelector', () => { }); wrapper.vm.$apollo.addSmartQuery = jest.fn((_, options) => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ [options.loadingKey]: true, }); @@ -161,6 +163,8 @@ describe('BoardsSelector', () => { // Emits gl-dropdown show event to simulate the dropdown is opened at initialization time findDropdown().vm.$emit('show'); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ loadingBoards: false, loadingRecentBoards: false, @@ -176,6 +180,8 @@ describe('BoardsSelector', () => { describe('filtering', () => { beforeEach(async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ boards, }); @@ -208,6 +214,8 @@ describe('BoardsSelector', () => { describe('recent boards section', () => { it('shows only when boards are greater than 10', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ boards, }); @@ -217,6 +225,8 @@ describe('BoardsSelector', () => { }); it('does not show when boards are less than 10', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ boards: boards.slice(0, 5), }); @@ -226,6 +236,8 @@ describe('BoardsSelector', () => { }); it('does not show when recentBoards api returns empty array', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ recentBoards: [], }); diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 51340a3ea4f..7c842d71688 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -29,6 +29,8 @@ import * as types from '~/boards/stores/mutation_types'; import mutations from '~/boards/stores/mutations'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import projectBoardMilestones from '~/boards/graphql/project_board_milestones.query.graphql'; +import groupBoardMilestones from '~/boards/graphql/group_board_milestones.query.graphql'; import { mockLists, mockListsById, @@ -308,6 +310,36 @@ describe('fetchMilestones', () => { expect(() => actions.fetchMilestones(store)).toThrow(new Error('Unknown board type')); }); + it.each([ + [ + 'project', + { + query: projectBoardMilestones, + variables: { fullPath: 'gitlab-org/gitlab', state: 'active' }, + }, + ], + [ + 'group', + { + query: groupBoardMilestones, + variables: { fullPath: 'gitlab-org/gitlab', state: 'active' }, + }, + ], + ])( + 'when boardType is %s it calls fetchMilestones with the correct query and variables', + (boardType, variables) => { + jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); + + const store = createStore(); + + store.state.boardType = boardType; + + actions.fetchMilestones(store); + + expect(gqlClient.query).toHaveBeenCalledWith(variables); + }, + ); + it('sets milestonesLoading to true', async () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); diff --git a/spec/frontend/branches/branches_delete_modal_spec.js b/spec/frontend/branches/branches_delete_modal_spec.js deleted file mode 100644 index 8b10cca7a11..00000000000 --- a/spec/frontend/branches/branches_delete_modal_spec.js +++ /dev/null @@ -1,40 +0,0 @@ -import $ from 'jquery'; -import DeleteModal from '~/branches/branches_delete_modal'; - -describe('branches delete modal', () => { - describe('setDisableDeleteButton', () => { - let submitSpy; - let $deleteButton; - - beforeEach(() => { - setFixtures(` - <div id="modal-delete-branch"> - <form> - <button type="submit" class="js-delete-branch">Delete</button> - </form> - </div> - `); - $deleteButton = $('.js-delete-branch'); - submitSpy = jest.fn((event) => event.preventDefault()); - $('#modal-delete-branch form').on('submit', submitSpy); - // eslint-disable-next-line no-new - new DeleteModal(); - }); - - it('does not submit if button is disabled', () => { - $deleteButton.attr('disabled', true); - - $deleteButton.click(); - - expect(submitSpy).not.toHaveBeenCalled(); - }); - - it('submits if button is not disabled', () => { - $deleteButton.attr('disabled', false); - - $deleteButton.click(); - - expect(submitSpy).toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/ci_lint/components/ci_lint_spec.js b/spec/frontend/ci_lint/components/ci_lint_spec.js index 70d116c12d3..c4b2927764e 100644 --- a/spec/frontend/ci_lint/components/ci_lint_spec.js +++ b/spec/frontend/ci_lint/components/ci_lint_spec.js @@ -66,6 +66,8 @@ describe('CI Lint', () => { it('validate action calls mutation with dry run', async () => { const dryRunEnabled = true; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ dryRun: dryRunEnabled }); findValidateBtn().vm.$emit('click'); diff --git a/spec/frontend/clusters/agents/components/show_spec.js b/spec/frontend/clusters/agents/components/show_spec.js index d5a8117f48c..2a3c11f4b47 100644 --- a/spec/frontend/clusters/agents/components/show_spec.js +++ b/spec/frontend/clusters/agents/components/show_spec.js @@ -19,7 +19,7 @@ describe('ClusterAgentShow', () => { let wrapper; useFakeDate([2021, 2, 15]); - const propsData = { + const provide = { agentName: 'cluster-agent', projectPath: 'path/to/project', }; @@ -49,7 +49,7 @@ describe('ClusterAgentShow', () => { shallowMount(ClusterAgentShow, { localVue, apolloProvider, - propsData, + provide, stubs: { GlSprintf, TimeAgoTooltip, GlTab }, }), ); @@ -60,7 +60,7 @@ describe('ClusterAgentShow', () => { wrapper = extendedWrapper( shallowMount(ClusterAgentShow, { - propsData, + provide, mocks: { $apollo, clusterAgent }, slots, stubs: { GlTab }, @@ -85,7 +85,7 @@ describe('ClusterAgentShow', () => { }); it('displays the agent name', () => { - expect(wrapper.text()).toContain(propsData.agentName); + expect(wrapper.text()).toContain(provide.agentName); }); it('displays agent create information', () => { diff --git a/spec/frontend/clusters/forms/components/integration_form_spec.js b/spec/frontend/clusters/forms/components/integration_form_spec.js index b129baa2d83..d041cd1e164 100644 --- a/spec/frontend/clusters/forms/components/integration_form_spec.js +++ b/spec/frontend/clusters/forms/components/integration_form_spec.js @@ -82,6 +82,8 @@ describe('ClusterIntegrationForm', () => { .then(() => { // setData is a bad approach because it changes the internal implementation which we should not touch // but our GlFormInput lacks the ability to set a new value. + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ toggleEnabled: !defaultStoreValues.enabled }); }) .then(() => { @@ -93,6 +95,8 @@ describe('ClusterIntegrationForm', () => { return wrapper.vm .$nextTick() .then(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ envScope: `${defaultStoreValues.environmentScope}1` }); }) .then(() => { diff --git a/spec/frontend/clusters_list/components/agent_options_spec.js b/spec/frontend/clusters_list/components/agent_options_spec.js new file mode 100644 index 00000000000..05bab247816 --- /dev/null +++ b/spec/frontend/clusters_list/components/agent_options_spec.js @@ -0,0 +1,211 @@ +import { GlDropdown, GlDropdownItem, GlModal, GlFormInput } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { ENTER_KEY } from '~/lib/utils/keys'; +import getAgentsQuery from '~/clusters_list/graphql/queries/get_agents.query.graphql'; +import deleteAgentMutation from '~/clusters_list/graphql/mutations/delete_agent.mutation.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import AgentOptions from '~/clusters_list/components/agent_options.vue'; +import { MAX_LIST_COUNT } from '~/clusters_list/constants'; +import { getAgentResponse, mockDeleteResponse, mockErrorDeleteResponse } from '../mocks/apollo'; + +Vue.use(VueApollo); + +const projectPath = 'path/to/project'; +const defaultBranchName = 'default'; +const maxAgents = MAX_LIST_COUNT; +const agent = { + id: 'agent-id', + name: 'agent-name', + webPath: 'agent-webPath', +}; + +describe('AgentOptions', () => { + let wrapper; + let toast; + let apolloProvider; + let deleteResponse; + + const findModal = () => wrapper.findComponent(GlModal); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDeleteBtn = () => wrapper.findComponent(GlDropdownItem); + const findInput = () => wrapper.findComponent(GlFormInput); + const findPrimaryAction = () => findModal().props('actionPrimary'); + const findPrimaryActionAttributes = (attr) => findPrimaryAction().attributes[0][attr]; + + const createMockApolloProvider = ({ mutationResponse }) => { + deleteResponse = jest.fn().mockResolvedValue(mutationResponse); + + return createMockApollo([[deleteAgentMutation, deleteResponse]]); + }; + + const writeQuery = () => { + apolloProvider.clients.defaultClient.cache.writeQuery({ + query: getAgentsQuery, + variables: { + projectPath, + defaultBranchName, + first: maxAgents, + last: null, + }, + data: getAgentResponse.data, + }); + }; + + const createWrapper = ({ mutationResponse = mockDeleteResponse } = {}) => { + apolloProvider = createMockApolloProvider({ mutationResponse }); + const provide = { + projectPath, + }; + const propsData = { + defaultBranchName, + maxAgents, + agent, + }; + + toast = jest.fn(); + + wrapper = shallowMountExtended(AgentOptions, { + apolloProvider, + provide, + propsData, + mocks: { $toast: { show: toast } }, + stubs: { GlModal }, + }); + wrapper.vm.$refs.modal.hide = jest.fn(); + + writeQuery(); + return wrapper.vm.$nextTick(); + }; + + const submitAgentToDelete = async () => { + findDeleteBtn().vm.$emit('click'); + findInput().vm.$emit('input', agent.name); + await findModal().vm.$emit('primary'); + }; + + beforeEach(() => { + return createWrapper({}); + }); + + afterEach(() => { + wrapper.destroy(); + apolloProvider = null; + deleteResponse = null; + toast = null; + }); + + describe('delete agent action', () => { + it('displays a delete button', () => { + expect(findDeleteBtn().text()).toBe('Delete agent'); + }); + + describe('when clicking the delete button', () => { + beforeEach(() => { + findDeleteBtn().vm.$emit('click'); + }); + + it('displays a delete confirmation modal', () => { + expect(findModal().isVisible()).toBe(true); + }); + }); + + describe.each` + condition | agentName | isDisabled | mutationCalled + ${'the input with agent name is missing'} | ${''} | ${true} | ${false} + ${'the input with agent name is incorrect'} | ${'wrong-name'} | ${true} | ${false} + ${'the input with agent name is correct'} | ${agent.name} | ${false} | ${true} + `('when $condition', ({ agentName, isDisabled, mutationCalled }) => { + beforeEach(() => { + findDeleteBtn().vm.$emit('click'); + findInput().vm.$emit('input', agentName); + }); + + it(`${isDisabled ? 'disables' : 'enables'} the modal primary button`, () => { + expect(findPrimaryActionAttributes('disabled')).toBe(isDisabled); + }); + + describe('when user clicks the modal primary button', () => { + beforeEach(async () => { + await findModal().vm.$emit('primary'); + }); + + if (mutationCalled) { + it('calls the delete mutation', () => { + expect(deleteResponse).toHaveBeenCalledWith({ input: { id: agent.id } }); + }); + } else { + it("doesn't call the delete mutation", () => { + expect(deleteResponse).not.toHaveBeenCalled(); + }); + } + }); + + describe('when user presses the enter button', () => { + beforeEach(async () => { + await findInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); + }); + + if (mutationCalled) { + it('calls the delete mutation', () => { + expect(deleteResponse).toHaveBeenCalledWith({ input: { id: agent.id } }); + }); + } else { + it("doesn't call the delete mutation", () => { + expect(deleteResponse).not.toHaveBeenCalled(); + }); + } + }); + }); + + describe('when agent was deleted successfully', () => { + beforeEach(async () => { + await submitAgentToDelete(); + }); + + it('calls the toast action', () => { + expect(toast).toHaveBeenCalledWith(`${agent.name} successfully deleted`); + }); + }); + }); + + describe('when getting an error deleting agent', () => { + beforeEach(async () => { + await createWrapper({ mutationResponse: mockErrorDeleteResponse }); + + submitAgentToDelete(); + }); + + it('displays the error message', () => { + expect(toast).toHaveBeenCalledWith('could not delete agent'); + }); + }); + + describe('when the delete modal was closed', () => { + beforeEach(async () => { + const loadingResponse = new Promise(() => {}); + await createWrapper({ mutationResponse: loadingResponse }); + + submitAgentToDelete(); + }); + + it('reenables the options dropdown', async () => { + expect(findPrimaryActionAttributes('loading')).toBe(true); + expect(findDropdown().attributes('disabled')).toBe('true'); + + await findModal().vm.$emit('hide'); + + expect(findPrimaryActionAttributes('loading')).toBe(false); + expect(findDropdown().attributes('disabled')).toBeUndefined(); + }); + + it('clears the agent name input', async () => { + expect(findInput().attributes('value')).toBe(agent.name); + + await findModal().vm.$emit('hide'); + + expect(findInput().attributes('value')).toBeUndefined(); + }); + }); +}); diff --git a/spec/frontend/clusters_list/components/agent_table_spec.js b/spec/frontend/clusters_list/components/agent_table_spec.js index a6d76b069cf..887c17bb4ad 100644 --- a/spec/frontend/clusters_list/components/agent_table_spec.js +++ b/spec/frontend/clusters_list/components/agent_table_spec.js @@ -1,16 +1,22 @@ import { GlLink, GlIcon } from '@gitlab/ui'; import AgentTable from '~/clusters_list/components/agent_table.vue'; +import AgentOptions from '~/clusters_list/components/agent_options.vue'; import { ACTIVE_CONNECTION_TIME } from '~/clusters_list/constants'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; import timeagoMixin from '~/vue_shared/mixins/timeago'; const connectedTimeNow = new Date(); const connectedTimeInactive = new Date(connectedTimeNow.getTime() - ACTIVE_CONNECTION_TIME); +const provideData = { + projectPath: 'path/to/project', +}; const propsData = { agents: [ { name: 'agent-1', + id: 'agent-1-id', configFolder: { webPath: '/agent/full/path', }, @@ -21,6 +27,7 @@ const propsData = { }, { name: 'agent-2', + id: 'agent-2-id', webPath: '/agent-2', status: 'active', lastContact: connectedTimeNow.getTime(), @@ -34,6 +41,7 @@ const propsData = { }, { name: 'agent-3', + id: 'agent-3-id', webPath: '/agent-3', status: 'inactive', lastContact: connectedTimeInactive.getTime(), @@ -48,6 +56,10 @@ const propsData = { ], }; +const AgentOptionsStub = stubComponent(AgentOptions, { + template: `<div></div>`, +}); + describe('AgentTable', () => { let wrapper; @@ -57,15 +69,21 @@ describe('AgentTable', () => { const findLastContactText = (at) => wrapper.findAllByTestId('cluster-agent-last-contact').at(at); const findConfiguration = (at) => wrapper.findAllByTestId('cluster-agent-configuration-link').at(at); + const findAgentOptions = () => wrapper.findAllComponents(AgentOptions); beforeEach(() => { - wrapper = mountExtended(AgentTable, { propsData }); + wrapper = mountExtended(AgentTable, { + propsData, + provide: provideData, + stubs: { + AgentOptions: AgentOptionsStub, + }, + }); }); afterEach(() => { if (wrapper) { wrapper.destroy(); - wrapper = null; } }); @@ -108,5 +126,9 @@ describe('AgentTable', () => { expect(findLink.exists()).toBe(hasLink); expect(findConfiguration(lineNumber).text()).toBe(agentPath); }); + + it('displays actions menu for each agent', () => { + expect(findAgentOptions()).toHaveLength(3); + }); }); }); diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js index a34202c789d..9af25a534d8 100644 --- a/spec/frontend/clusters_list/components/clusters_spec.js +++ b/spec/frontend/clusters_list/components/clusters_spec.js @@ -272,6 +272,8 @@ describe('Clusters', () => { describe('when updating currentPage', () => { beforeEach(() => { mockPollingApi(200, apiData, paginationHeader(totalSecondPage, perPage, 2)); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentPage: 2 }); return axios.waitForAll(); }); diff --git a/spec/frontend/clusters_list/mocks/apollo.js b/spec/frontend/clusters_list/mocks/apollo.js index 804f9834506..c4a31ed4394 100644 --- a/spec/frontend/clusters_list/mocks/apollo.js +++ b/spec/frontend/clusters_list/mocks/apollo.js @@ -75,3 +75,15 @@ export const getAgentResponse = { }, }, }; + +export const mockDeleteResponse = { + data: { clusterAgentDelete: { errors: [] } }, +}; + +export const mockErrorDeleteResponse = { + data: { + clusterAgentDelete: { + errors: ['could not delete agent'], + }, + }, +}; diff --git a/spec/frontend/commit/pipelines/pipelines_table_spec.js b/spec/frontend/commit/pipelines/pipelines_table_spec.js index c376b58cc72..e209f628aa2 100644 --- a/spec/frontend/commit/pipelines/pipelines_table_spec.js +++ b/spec/frontend/commit/pipelines/pipelines_table_spec.js @@ -92,6 +92,8 @@ describe('Pipelines table in Commits and Merge requests', () => { it('should make an API request when using pagination', async () => { jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {}); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ store: { state: { diff --git a/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js b/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js index de8f8efd260..415f1314a36 100644 --- a/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js +++ b/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js @@ -26,6 +26,11 @@ describe('content/components/wrappers/frontmatter', () => { expect(wrapper.findComponent(NodeViewWrapper).classes()).toContain('gl-relative'); }); + it('adds content-editor-code-block class to the pre element', () => { + createWrapper(); + expect(wrapper.findComponent(NodeViewWrapper).classes()).toContain('content-editor-code-block'); + }); + it('renders a node-view-content as a code element', () => { createWrapper(); diff --git a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js index 6a0a0c76825..05fa0f79ef0 100644 --- a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js +++ b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js @@ -36,4 +36,10 @@ describe('content_editor/extensions/code_block_highlight', () => { expect(editorHtmlOutput.classList.toString()).toContain('code highlight js-syntax-highlight'); }); + + it('adds content-editor-code-block class to the pre element', () => { + const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); + + expect(editorHtmlOutput.classList.toString()).toContain('content-editor-code-block'); + }); }); diff --git a/spec/frontend/content_editor/extensions/code_spec.js b/spec/frontend/content_editor/extensions/code_spec.js new file mode 100644 index 00000000000..0a54ac6a96b --- /dev/null +++ b/spec/frontend/content_editor/extensions/code_spec.js @@ -0,0 +1,8 @@ +import Code from '~/content_editor/extensions/code'; +import { EXTENSION_PRIORITY_LOWER } from '~/content_editor/constants'; + +describe('content_editor/extensions/code', () => { + it('has a lower loading priority', () => { + expect(Code.config.priority).toBe(EXTENSION_PRIORITY_LOWER); + }); +}); diff --git a/spec/frontend/content_editor/extensions/frontmatter_spec.js b/spec/frontend/content_editor/extensions/frontmatter_spec.js index 517f6947b9a..a8cbad6ef81 100644 --- a/spec/frontend/content_editor/extensions/frontmatter_spec.js +++ b/spec/frontend/content_editor/extensions/frontmatter_spec.js @@ -1,30 +1,47 @@ import Frontmatter from '~/content_editor/extensions/frontmatter'; +import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils'; describe('content_editor/extensions/frontmatter', () => { let tiptapEditor; let doc; - let p; + let frontmatter; + let codeBlock; beforeEach(() => { - tiptapEditor = createTestEditor({ extensions: [Frontmatter] }); + tiptapEditor = createTestEditor({ extensions: [Frontmatter, CodeBlockHighlight] }); ({ - builders: { doc, p }, + builders: { doc, codeBlock, frontmatter }, } = createDocBuilder({ tiptapEditor, names: { frontmatter: { nodeType: Frontmatter.name }, + codeBlock: { nodeType: CodeBlockHighlight.name }, }, })); }); it('does not insert a frontmatter block when executing code block input rule', () => { - const expectedDoc = doc(p('')); + const expectedDoc = doc(codeBlock('')); const inputRuleText = '``` '; triggerNodeInputRule({ tiptapEditor, inputRuleText }); expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); }); + + it.each` + command | result | resultDesc + ${'toggleCodeBlock'} | ${() => doc(codeBlock(''))} | ${'code block element'} + ${'setCodeBlock'} | ${() => doc(codeBlock(''))} | ${'code block element'} + ${'setFrontmatter'} | ${() => doc(frontmatter(''))} | ${'frontmatter element'} + ${'toggleFrontmatter'} | ${() => doc(frontmatter(''))} | ${'frontmatter element'} + `('executing $command should generate a document with a $resultDesc', ({ command, result }) => { + const expectedDoc = result(); + + tiptapEditor.commands[command](); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); }); diff --git a/spec/frontend/content_editor/extensions/image_spec.js b/spec/frontend/content_editor/extensions/image_spec.js new file mode 100644 index 00000000000..256f7bad309 --- /dev/null +++ b/spec/frontend/content_editor/extensions/image_spec.js @@ -0,0 +1,41 @@ +import Image from '~/content_editor/extensions/image'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/extensions/image', () => { + let tiptapEditor; + let doc; + let p; + let image; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [Image] }); + + ({ + builders: { doc, p, image }, + } = createDocBuilder({ + tiptapEditor, + names: { + image: { nodeType: Image.name }, + }, + })); + }); + + it('adds data-canonical-src attribute when rendering to HTML', () => { + const initialDoc = doc( + p( + image({ + canonicalSrc: 'uploads/image.jpg', + src: '/-/wikis/uploads/image.jpg', + alt: 'image', + title: 'this is an image', + }), + ), + ); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + + expect(tiptapEditor.getHTML()).toEqual( + '<p><img src="/-/wikis/uploads/image.jpg" alt="image" title="this is an image" data-canonical-src="uploads/image.jpg"></p>', + ); + }); +}); diff --git a/spec/frontend/content_editor/extensions/link_spec.js b/spec/frontend/content_editor/extensions/link_spec.js index ead898554d1..bb841357d37 100644 --- a/spec/frontend/content_editor/extensions/link_spec.js +++ b/spec/frontend/content_editor/extensions/link_spec.js @@ -33,7 +33,7 @@ describe('content_editor/extensions/link', () => { ${'documentation](readme.md'} | ${() => p('documentation](readme.md')} ${'http://example.com '} | ${() => p(link({ href: 'http://example.com' }, 'http://example.com'))} ${'https://example.com '} | ${() => p(link({ href: 'https://example.com' }, 'https://example.com'))} - ${'www.example.com '} | ${() => p(link({ href: 'www.example.com' }, 'www.example.com'))} + ${'www.example.com '} | ${() => p(link({ href: 'http://www.example.com' }, 'www.example.com'))} ${'example.com/ab.html '} | ${() => p('example.com/ab.html')} ${'https://www.google.com '} | ${() => p(link({ href: 'https://www.google.com' }, 'https://www.google.com'))} `('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => { diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 97f6d8f6334..01d4c994e88 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -164,6 +164,17 @@ describe('markdownSerializer', () => { expect(serialize(paragraph(italic('italics')))).toBe('_italics_'); }); + it('correctly serializes code blocks wrapped by italics and bold marks', () => { + const text = 'code block'; + + expect(serialize(paragraph(italic(code(text))))).toBe(`_\`${text}\`_`); + expect(serialize(paragraph(code(italic(text))))).toBe(`_\`${text}\`_`); + expect(serialize(paragraph(bold(code(text))))).toBe(`**\`${text}\`**`); + expect(serialize(paragraph(code(bold(text))))).toBe(`**\`${text}\`**`); + expect(serialize(paragraph(strike(code(text))))).toBe(`~~\`${text}\`~~`); + expect(serialize(paragraph(code(strike(text))))).toBe(`~~\`${text}\`~~`); + }); + it('correctly serializes inline diff', () => { expect( serialize( @@ -341,6 +352,10 @@ this is not really json but just trying out whether this case works or not ); }); + it('does not serialize an image when src and canonicalSrc are empty', () => { + expect(serialize(paragraph(image({})))).toBe(''); + }); + it('correctly serializes an image with a title', () => { expect(serialize(paragraph(image({ src: 'img.jpg', title: 'baz', alt: 'foo bar' })))).toBe( '![foo bar](img.jpg "baz")', diff --git a/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js b/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js index 0c6095e601f..4e92fa1df16 100644 --- a/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js +++ b/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js @@ -206,6 +206,8 @@ describe('ClusterFormDropdown', () => { const searchQuery = secondItem.name; wrapper.setProps({ items }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchQuery }); return wrapper.vm.$nextTick().then(() => { diff --git a/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js index d866ffd4efb..a0510d46794 100644 --- a/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js @@ -67,6 +67,8 @@ describe('ServiceCredentialsForm', () => { }); it('enables submit button when role ARN is not provided', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ roleArn: '123' }); return vm.vm.$nextTick().then(() => { @@ -75,6 +77,8 @@ describe('ServiceCredentialsForm', () => { }); it('dispatches createRole action when submit button is clicked', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ roleArn: '123' }); // set role ARN to enable button findSubmitButton().vm.$emit('click', new Event('click')); @@ -84,6 +88,8 @@ describe('ServiceCredentialsForm', () => { describe('when is creating role', () => { beforeEach(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ roleArn: '123' }); // set role ARN to enable button state.isCreatingRole = true; diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js index 8f4903dd91b..2b6f2134553 100644 --- a/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js +++ b/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js @@ -79,6 +79,8 @@ describe('GkeMachineTypeDropdown', () => { store = createStore(); wrapper = createComponent(store); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isLoading: true }); return wrapper.vm.$nextTick().then(() => { diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js index b191b107609..2b0acc8cf5d 100644 --- a/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js +++ b/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js @@ -83,6 +83,8 @@ describe('GkeProjectIdDropdown', () => { it('returns default toggle text', () => { bootstrap(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isLoading: false }); return wrapper.vm.$nextTick().then(() => { @@ -99,6 +101,8 @@ describe('GkeProjectIdDropdown', () => { hasProject: () => true, }, ); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isLoading: false }); return wrapper.vm.$nextTick().then(() => { @@ -110,6 +114,8 @@ describe('GkeProjectIdDropdown', () => { bootstrap({ projects: null, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isLoading: false }); return wrapper.vm.$nextTick().then(() => { diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_zone_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_zone_dropdown_spec.js index 4054b768e34..22fc681f863 100644 --- a/spec/frontend/create_cluster/gke_cluster/components/gke_zone_dropdown_spec.js +++ b/spec/frontend/create_cluster/gke_cluster/components/gke_zone_dropdown_spec.js @@ -47,6 +47,8 @@ describe('GkeZoneDropdown', () => { describe('isLoading', () => { beforeEach(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isLoading: true }); return wrapper.vm.$nextTick(); }); diff --git a/spec/frontend/crm/contact_form_spec.js b/spec/frontend/crm/contact_form_spec.js index b2753ad8cf5..0edab4f5ec5 100644 --- a/spec/frontend/crm/contact_form_spec.js +++ b/spec/frontend/crm/contact_form_spec.js @@ -112,7 +112,7 @@ describe('Customer relations contact form component', () => { await waitForPromises(); expect(findError().exists()).toBe(true); - expect(findError().text()).toBe('Phone is invalid.'); + expect(findError().text()).toBe('create contact is invalid.'); }); }); @@ -151,7 +151,7 @@ describe('Customer relations contact form component', () => { await waitForPromises(); expect(findError().exists()).toBe(true); - expect(findError().text()).toBe('Email is invalid.'); + expect(findError().text()).toBe('update contact is invalid.'); }); }); }); diff --git a/spec/frontend/crm/form_spec.js b/spec/frontend/crm/form_spec.js new file mode 100644 index 00000000000..0e3abc05c37 --- /dev/null +++ b/spec/frontend/crm/form_spec.js @@ -0,0 +1,278 @@ +import { GlAlert } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import Form from '~/crm/components/form.vue'; +import routes from '~/crm/routes'; +import createContactMutation from '~/crm/components/queries/create_contact.mutation.graphql'; +import updateContactMutation from '~/crm/components/queries/update_contact.mutation.graphql'; +import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql'; +import createOrganizationMutation from '~/crm/components/queries/create_organization.mutation.graphql'; +import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql'; +import { + createContactMutationErrorResponse, + createContactMutationResponse, + getGroupContactsQueryResponse, + updateContactMutationErrorResponse, + updateContactMutationResponse, + createOrganizationMutationErrorResponse, + createOrganizationMutationResponse, + getGroupOrganizationsQueryResponse, +} from './mock_data'; + +const FORM_CREATE_CONTACT = 'create contact'; +const FORM_UPDATE_CONTACT = 'update contact'; +const FORM_CREATE_ORG = 'create organization'; + +describe('Reusable form component', () => { + Vue.use(VueApollo); + Vue.use(VueRouter); + + const DEFAULT_RESPONSES = { + createContact: Promise.resolve(createContactMutationResponse), + updateContact: Promise.resolve(updateContactMutationResponse), + createOrg: Promise.resolve(createOrganizationMutationResponse), + }; + + let wrapper; + let handler; + let fakeApollo; + let router; + + beforeEach(() => { + router = new VueRouter({ + base: '', + mode: 'history', + routes, + }); + router.push('/test'); + + handler = jest.fn().mockImplementation((key) => DEFAULT_RESPONSES[key]); + + const hanlderWithKey = (key) => (...args) => handler(key, ...args); + + fakeApollo = createMockApollo([ + [createContactMutation, hanlderWithKey('createContact')], + [updateContactMutation, hanlderWithKey('updateContact')], + [createOrganizationMutation, hanlderWithKey('createOrg')], + ]); + + fakeApollo.clients.defaultClient.cache.writeQuery({ + query: getGroupContactsQuery, + variables: { groupFullPath: 'flightjs' }, + data: getGroupContactsQueryResponse.data, + }); + + fakeApollo.clients.defaultClient.cache.writeQuery({ + query: getGroupOrganizationsQuery, + variables: { groupFullPath: 'flightjs' }, + data: getGroupOrganizationsQueryResponse.data, + }); + }); + + const mockToastShow = jest.fn(); + + const findSaveButton = () => wrapper.findByTestId('save-button'); + const findForm = () => wrapper.find('form'); + const findError = () => wrapper.findComponent(GlAlert); + + const mountComponent = (propsData) => { + wrapper = shallowMountExtended(Form, { + router, + apolloProvider: fakeApollo, + propsData: { drawerOpen: true, ...propsData }, + mocks: { + $toast: { + show: mockToastShow, + }, + }, + }); + }; + + const mountContact = ({ propsData } = {}) => { + mountComponent({ + fields: [ + { name: 'firstName', label: 'First name', required: true }, + { name: 'lastName', label: 'Last name', required: true }, + { name: 'email', label: 'Email', required: true }, + { name: 'phone', label: 'Phone' }, + { name: 'description', label: 'Description' }, + ], + ...propsData, + }); + }; + + const mountContactCreate = () => { + const propsData = { + title: 'New contact', + successMessage: 'Contact has been added', + buttonLabel: 'Create contact', + getQuery: { + query: getGroupContactsQuery, + variables: { groupFullPath: 'flightjs' }, + }, + getQueryNodePath: 'group.contacts', + mutation: createContactMutation, + additionalCreateParams: { groupId: 'gid://gitlab/Group/26' }, + }; + mountContact({ propsData }); + }; + + const mountContactUpdate = () => { + const propsData = { + title: 'Edit contact', + successMessage: 'Contact has been updated', + mutation: updateContactMutation, + existingModel: { + id: 'gid://gitlab/CustomerRelations::Contact/12', + firstName: 'First', + lastName: 'Last', + email: 'email@example.com', + }, + }; + mountContact({ propsData }); + }; + + const mountOrganization = ({ propsData } = {}) => { + mountComponent({ + fields: [ + { name: 'name', label: 'Name', required: true }, + { name: 'defaultRate', label: 'Default rate', input: { type: 'number', step: '0.01' } }, + { name: 'description', label: 'Description' }, + ], + ...propsData, + }); + }; + + const mountOrganizationCreate = () => { + const propsData = { + title: 'New organization', + successMessage: 'Organization has been added', + buttonLabel: 'Create organization', + getQuery: { + query: getGroupOrganizationsQuery, + variables: { groupFullPath: 'flightjs' }, + }, + getQueryNodePath: 'group.organizations', + mutation: createOrganizationMutation, + additionalCreateParams: { groupId: 'gid://gitlab/Group/26' }, + }; + mountOrganization({ propsData }); + }; + + const forms = { + [FORM_CREATE_CONTACT]: { + mountFunction: mountContactCreate, + mutationErrorResponse: createContactMutationErrorResponse, + toastMessage: 'Contact has been added', + }, + [FORM_UPDATE_CONTACT]: { + mountFunction: mountContactUpdate, + mutationErrorResponse: updateContactMutationErrorResponse, + toastMessage: 'Contact has been updated', + }, + [FORM_CREATE_ORG]: { + mountFunction: mountOrganizationCreate, + mutationErrorResponse: createOrganizationMutationErrorResponse, + toastMessage: 'Organization has been added', + }, + }; + const asTestParams = (...keys) => keys.map((name) => [name, forms[name]]); + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each(asTestParams(FORM_CREATE_CONTACT, FORM_UPDATE_CONTACT))( + '%s form save button', + (name, { mountFunction }) => { + beforeEach(() => { + mountFunction(); + }); + + it('should be disabled when required fields are empty', async () => { + wrapper.find('#firstName').vm.$emit('input', ''); + await waitForPromises(); + + expect(findSaveButton().props('disabled')).toBe(true); + }); + + it('should not be disabled when required fields have values', async () => { + wrapper.find('#firstName').vm.$emit('input', 'A'); + wrapper.find('#lastName').vm.$emit('input', 'B'); + wrapper.find('#email').vm.$emit('input', 'C'); + await waitForPromises(); + + expect(findSaveButton().props('disabled')).toBe(false); + }); + }, + ); + + describe.each(asTestParams(FORM_CREATE_ORG))('%s form save button', (name, { mountFunction }) => { + beforeEach(() => { + mountFunction(); + }); + + it('should be disabled when required field is empty', async () => { + wrapper.find('#name').vm.$emit('input', ''); + await waitForPromises(); + + expect(findSaveButton().props('disabled')).toBe(true); + }); + + it('should not be disabled when required field has a value', async () => { + wrapper.find('#name').vm.$emit('input', 'A'); + await waitForPromises(); + + expect(findSaveButton().props('disabled')).toBe(false); + }); + }); + + describe.each(asTestParams(FORM_CREATE_CONTACT, FORM_UPDATE_CONTACT, FORM_CREATE_ORG))( + 'when %s mutation is successful', + (name, { mountFunction, toastMessage }) => { + it('form should display correct toast message', async () => { + mountFunction(); + + findForm().trigger('submit'); + await waitForPromises(); + + expect(mockToastShow).toHaveBeenCalledWith(toastMessage); + }); + }, + ); + + describe.each(asTestParams(FORM_CREATE_CONTACT, FORM_UPDATE_CONTACT, FORM_CREATE_ORG))( + 'when %s mutation fails', + (formName, { mutationErrorResponse, mountFunction }) => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(); + }); + + it('should show error on reject', async () => { + handler.mockRejectedValue('ERROR'); + + mountFunction(); + + findForm().trigger('submit'); + await waitForPromises(); + + expect(findError().text()).toBe('Something went wrong. Please try again.'); + }); + + it('should show error on error response', async () => { + handler.mockResolvedValue(mutationErrorResponse); + + mountFunction(); + + findForm().trigger('submit'); + await waitForPromises(); + + expect(findError().text()).toBe(`${formName} is invalid.`); + }); + }, + ); +}); diff --git a/spec/frontend/crm/mock_data.js b/spec/frontend/crm/mock_data.js index f7af2ccdb72..e351e101b29 100644 --- a/spec/frontend/crm/mock_data.js +++ b/spec/frontend/crm/mock_data.js @@ -82,7 +82,6 @@ export const getGroupOrganizationsQueryResponse = { export const createContactMutationResponse = { data: { customerRelationsContactCreate: { - __typeName: 'CustomerRelationsContactCreatePayload', contact: { __typename: 'CustomerRelationsContact', id: 'gid://gitlab/CustomerRelations::Contact/1', @@ -102,7 +101,7 @@ export const createContactMutationErrorResponse = { data: { customerRelationsContactCreate: { contact: null, - errors: ['Phone is invalid.'], + errors: ['create contact is invalid.'], }, }, }; @@ -130,7 +129,7 @@ export const updateContactMutationErrorResponse = { data: { customerRelationsContactUpdate: { contact: null, - errors: ['Email is invalid.'], + errors: ['update contact is invalid.'], }, }, }; @@ -138,7 +137,6 @@ export const updateContactMutationErrorResponse = { export const createOrganizationMutationResponse = { data: { customerRelationsOrganizationCreate: { - __typeName: 'CustomerRelationsOrganizationCreatePayload', organization: { __typename: 'CustomerRelationsOrganization', id: 'gid://gitlab/CustomerRelations::Organization/2', @@ -155,7 +153,7 @@ export const createOrganizationMutationErrorResponse = { data: { customerRelationsOrganizationCreate: { organization: null, - errors: ['Name cannot be blank.'], + errors: ['create organization is invalid.'], }, }, }; diff --git a/spec/frontend/crm/new_organization_form_spec.js b/spec/frontend/crm/new_organization_form_spec.js index 976b626f35f..0a7909774c9 100644 --- a/spec/frontend/crm/new_organization_form_spec.js +++ b/spec/frontend/crm/new_organization_form_spec.js @@ -103,7 +103,7 @@ describe('Customer relations organizations root app', () => { await waitForPromises(); expect(findError().exists()).toBe(true); - expect(findError().text()).toBe('Name cannot be blank.'); + expect(findError().text()).toBe('create organization is invalid.'); }); }); }); diff --git a/spec/frontend/cycle_analytics/stage_table_spec.js b/spec/frontend/cycle_analytics/stage_table_spec.js index 3158446c37d..9605dce2668 100644 --- a/spec/frontend/cycle_analytics/stage_table_spec.js +++ b/spec/frontend/cycle_analytics/stage_table_spec.js @@ -24,6 +24,7 @@ const findTable = () => wrapper.findComponent(GlTable); const findTableHead = () => wrapper.find('thead'); const findTableHeadColumns = () => findTableHead().findAll('th'); const findStageEventTitle = (ev) => extendedWrapper(ev).findByTestId('vsa-stage-event-title'); +const findStageEventLink = (ev) => extendedWrapper(ev).findByTestId('vsa-stage-event-link'); const findStageTime = () => wrapper.findByTestId('vsa-stage-event-time'); const findIcon = (name) => wrapper.findByTestId(`${name}-icon`); @@ -86,6 +87,15 @@ describe('StageTable', () => { expect(titles[index]).toBe(ev.title); }); }); + + it('will not display the project name in the record link', () => { + const evs = findStageEvents(); + + const links = evs.wrappers.map((ev) => findStageEventLink(ev).text()); + issueEventItems.forEach((ev, index) => { + expect(links[index]).toBe(`#${ev.iid}`); + }); + }); }); describe('default event', () => { @@ -187,6 +197,53 @@ describe('StageTable', () => { }); }); + describe('includeProjectName set', () => { + const fakenamespace = 'some/fake/path'; + beforeEach(() => { + wrapper = createComponent({ includeProjectName: true }); + }); + + it('will display the project name in the record link', () => { + const evs = findStageEvents(); + + const links = evs.wrappers.map((ev) => findStageEventLink(ev).text()); + issueEventItems.forEach((ev, index) => { + expect(links[index]).toBe(`${ev.projectPath}#${ev.iid}`); + }); + }); + + describe.each` + namespaceFullPath | hasFullPath + ${'fake'} | ${false} + ${fakenamespace} | ${true} + `('with a namespace', ({ namespaceFullPath, hasFullPath }) => { + let evs = null; + let links = null; + + beforeEach(() => { + wrapper = createComponent({ + includeProjectName: true, + stageEvents: issueEventItems.map((ie) => ({ ...ie, namespaceFullPath })), + }); + + evs = findStageEvents(); + links = evs.wrappers.map((ev) => findStageEventLink(ev).text()); + }); + + it(`with namespaceFullPath='${namespaceFullPath}' ${ + hasFullPath ? 'will' : 'does not' + } include the namespace`, () => { + issueEventItems.forEach((ev, index) => { + if (hasFullPath) { + expect(links[index]).toBe(`${namespaceFullPath}/${ev.projectPath}#${ev.iid}`); + } else { + expect(links[index]).toBe(`${ev.projectPath}#${ev.iid}`); + } + }); + }); + }); + }); + describe('Pagination', () => { beforeEach(() => { wrapper = createComponent(); diff --git a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js index c97e4845bc2..082db2cc312 100644 --- a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js +++ b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js @@ -63,6 +63,8 @@ describe('ValueStreamMetrics', () => { it('renders hidden GlSingleStat components for each metric', async () => { await waitForPromises(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isLoading: true }); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js b/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js index 4dd5c29a917..5f4d4071f29 100644 --- a/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js +++ b/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js @@ -26,6 +26,8 @@ describe('Deploy freeze timezone dropdown', () => { }, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchTerm }); }; diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_signed_out_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_signed_out_spec.js.snap new file mode 100644 index 00000000000..ab37cb90bd3 --- /dev/null +++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_signed_out_spec.js.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DesignNoteSignedOut renders message containing register and sign-in links while user wants to reply to a discussion 1`] = ` +<div + class="disabled-comment text-center" +> + Please + <gl-link-stub + href="/users/sign_up?redirect_to_referer=yes" + > + register + </gl-link-stub> + or + <gl-link-stub + href="/users/sign_in?redirect_to_referer=yes" + > + sign in + </gl-link-stub> + to reply. +</div> +`; + +exports[`DesignNoteSignedOut renders message containing register and sign-in links while user wants to start a new discussion 1`] = ` +<div + class="disabled-comment text-center" +> + Please + <gl-link-stub + href="/users/sign_up?redirect_to_referer=yes" + > + register + </gl-link-stub> + or + <gl-link-stub + href="/users/sign_in?redirect_to_referer=yes" + > + sign in + </gl-link-stub> + to start a new discussion. +</div> +`; diff --git a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js index 9335d800a16..e816a05ba53 100644 --- a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js @@ -1,7 +1,9 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import { ApolloMutation } from 'vue-apollo'; import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue'; import DesignNote from '~/design_management/components/design_notes/design_note.vue'; +import DesignNoteSignedOut from '~/design_management/components/design_notes/design_note_signed_out.vue'; import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue'; import ToggleRepliesWidget from '~/design_management/components/design_notes/toggle_replies_widget.vue'; import createNoteMutation from '~/design_management/graphql/mutations/create_note.mutation.graphql'; @@ -20,6 +22,7 @@ const defaultMockDiscussion = { const DEFAULT_TODO_COUNT = 2; describe('Design discussions component', () => { + const originalGon = window.gon; let wrapper; const findDesignNotes = () => wrapper.findAll(DesignNote); @@ -31,6 +34,7 @@ describe('Design discussions component', () => { const findResolvedMessage = () => wrapper.find('[data-testid="resolved-message"]'); const findResolveLoadingIcon = () => wrapper.find(GlLoadingIcon); const findResolveCheckbox = () => wrapper.find('[data-testid="resolve-checkbox"]'); + const findApolloMutation = () => wrapper.findComponent(ApolloMutation); const mutationVariables = { mutation: createNoteMutation, @@ -42,6 +46,8 @@ describe('Design discussions component', () => { }, }, }; + const registerPath = '/users/sign_up?redirect_to_referer=yes'; + const signInPath = '/users/sign_in?redirect_to_referer=yes'; const mutate = jest.fn().mockResolvedValue({ data: { createNote: { errors: [] } } }); const readQuery = jest.fn().mockReturnValue({ project: { @@ -62,6 +68,8 @@ describe('Design discussions component', () => { designId: 'design-id', discussionIndex: 1, discussionWithOpenForm: '', + registerPath, + signInPath, ...props, }, data() { @@ -88,8 +96,13 @@ describe('Design discussions component', () => { }); } + beforeEach(() => { + window.gon = { current_user_id: 1 }; + }); + afterEach(() => { wrapper.destroy(); + window.gon = originalGon; }); describe('when discussion is not resolvable', () => { @@ -349,4 +362,41 @@ describe('Design discussions component', () => { expect(wrapper.emitted('open-form')).toBeTruthy(); }); + + describe('when user is not logged in', () => { + const findDesignNoteSignedOut = () => wrapper.findComponent(DesignNoteSignedOut); + + beforeEach(() => { + window.gon = { current_user_id: null }; + createComponent( + { + discussion: { + ...defaultMockDiscussion, + }, + discussionWithOpenForm: defaultMockDiscussion.id, + }, + { discussionComment: 'test', isFormRendered: true }, + ); + }); + + it('does not render resolve discussion button', () => { + expect(findResolveButton().exists()).toBe(false); + }); + + it('does not render replace-placeholder component', () => { + expect(findReplyPlaceholder().exists()).toBe(false); + }); + + it('does not render apollo-mutation component', () => { + expect(findApolloMutation().exists()).toBe(false); + }); + + it('renders design-note-signed-out component', () => { + expect(findDesignNoteSignedOut().exists()).toBe(true); + expect(findDesignNoteSignedOut().props()).toMatchObject({ + registerPath, + signInPath, + }); + }); + }); }); diff --git a/spec/frontend/design_management/components/design_notes/design_note_signed_out_spec.js b/spec/frontend/design_management/components/design_notes/design_note_signed_out_spec.js new file mode 100644 index 00000000000..e71bb5ab520 --- /dev/null +++ b/spec/frontend/design_management/components/design_notes/design_note_signed_out_spec.js @@ -0,0 +1,36 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import DesignNoteSignedOut from '~/design_management/components/design_notes/design_note_signed_out.vue'; + +function createComponent(isAddDiscussion = false) { + return shallowMount(DesignNoteSignedOut, { + propsData: { + registerPath: '/users/sign_up?redirect_to_referer=yes', + signInPath: '/users/sign_in?redirect_to_referer=yes', + isAddDiscussion, + }, + stubs: { + GlSprintf, + }, + }); +} + +describe('DesignNoteSignedOut', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders message containing register and sign-in links while user wants to reply to a discussion', () => { + wrapper = createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders message containing register and sign-in links while user wants to start a new discussion', () => { + wrapper = createComponent(true); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/design_management/components/design_overlay_spec.js b/spec/frontend/design_management/components/design_overlay_spec.js index d3119be7159..4bda5054090 100644 --- a/spec/frontend/design_management/components/design_overlay_spec.js +++ b/spec/frontend/design_management/components/design_overlay_spec.js @@ -117,6 +117,8 @@ describe('Design overlay component', () => { it.each([notes[0].discussion.notes.nodes[1], notes[0].discussion.notes.nodes[0]])( 'should not apply inactive class to the pin for the active discussion', (note) => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ activeDiscussion: { id: note.id, @@ -131,6 +133,8 @@ describe('Design overlay component', () => { ); it('should apply inactive class to all pins besides the active one', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ activeDiscussion: { id: notes[0].id, @@ -212,6 +216,8 @@ describe('Design overlay component', () => { const { position } = note; const newCoordinates = { x: 20, y: 20 }; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ movingNoteNewPosition: { ...position, @@ -345,6 +351,8 @@ describe('Design overlay component', () => { }); const newCoordinates = { x: 20, y: 20 }; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ movingNoteStartPosition: { ...notes[0].position, @@ -368,6 +376,8 @@ describe('Design overlay component', () => { it('should calculate delta correctly from state', () => { createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ movingNoteStartPosition: { clientX: 10, diff --git a/spec/frontend/design_management/components/design_presentation_spec.js b/spec/frontend/design_management/components/design_presentation_spec.js index edf8b965153..adec9ef469d 100644 --- a/spec/frontend/design_management/components/design_presentation_spec.js +++ b/spec/frontend/design_management/components/design_presentation_spec.js @@ -15,6 +15,7 @@ const mockOverlayData = { }; describe('Design management design presentation component', () => { + const originalGon = window.gon; let wrapper; function createComponent( @@ -39,6 +40,8 @@ describe('Design management design presentation component', () => { stubs, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData(data); wrapper.element.scrollTo = jest.fn(); } @@ -113,8 +116,13 @@ describe('Design management design presentation component', () => { }); } + beforeEach(() => { + window.gon = { current_user_id: 1 }; + }); + afterEach(() => { wrapper.destroy(); + window.gon = originalGon; }); it('renders image and overlay when image provided', () => { @@ -550,4 +558,23 @@ describe('Design management design presentation component', () => { }); }); }); + + describe('when user is not logged in', () => { + beforeEach(() => { + window.gon = { current_user_id: null }; + createComponent( + { + image: 'test.jpg', + imageName: 'test', + }, + mockOverlayData, + ); + }); + + it('disables commenting from design overlay', () => { + expect(wrapper.findComponent(DesignOverlay).props()).toMatchObject({ + disableCommenting: true, + }); + }); + }); }); diff --git a/spec/frontend/design_management/components/design_sidebar_spec.js b/spec/frontend/design_management/components/design_sidebar_spec.js index 8eb993ec7b5..4cd71bdb7f3 100644 --- a/spec/frontend/design_management/components/design_sidebar_spec.js +++ b/spec/frontend/design_management/components/design_sidebar_spec.js @@ -2,6 +2,7 @@ import { GlCollapse, GlPopover } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Cookies from 'js-cookie'; import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue'; +import DesignNoteSignedOut from '~/design_management/components/design_notes/design_note_signed_out.vue'; import DesignSidebar from '~/design_management/components/design_sidebar.vue'; import DesignTodoButton from '~/design_management/components/design_todo_button.vue'; import updateActiveDiscussionMutation from '~/design_management/graphql/mutations/update_active_discussion.mutation.graphql'; @@ -30,6 +31,7 @@ const cookieKey = 'hide_design_resolved_comments_popover'; const mutate = jest.fn().mockResolvedValue(); describe('Design management design sidebar component', () => { + const originalGon = window.gon; let wrapper; const findDiscussions = () => wrapper.findAll(DesignDiscussion); @@ -58,11 +60,20 @@ describe('Design management design sidebar component', () => { }, }, stubs: { GlPopover }, + provide: { + registerPath: '/users/sign_up?redirect_to_referer=yes', + signInPath: '/users/sign_in?redirect_to_referer=yes', + }, }); } + beforeEach(() => { + window.gon = { current_user_id: 1 }; + }); + afterEach(() => { wrapper.destroy(); + window.gon = originalGon; }); it('renders participants', () => { @@ -248,4 +259,44 @@ describe('Design management design sidebar component', () => { expect(Cookies.set).toHaveBeenCalledWith(cookieKey, 'true', { expires: 365 * 10 }); }); }); + + describe('when user is not logged in', () => { + const findDesignNoteSignedOut = () => wrapper.findComponent(DesignNoteSignedOut); + + beforeEach(() => { + window.gon = { current_user_id: null }; + }); + + describe('design has no discussions', () => { + beforeEach(() => { + createComponent({ + design: { + ...design, + discussions: { + nodes: [], + }, + }, + }); + }); + + it('does not render a message about possibility to create a new discussion', () => { + expect(findNewDiscussionDisclaimer().exists()).toBe(false); + }); + + it('renders design-note-signed-out component', () => { + expect(findDesignNoteSignedOut().exists()).toBe(true); + }); + }); + + describe('design has discussions', () => { + beforeEach(() => { + Cookies.set(cookieKey, true); + createComponent(); + }); + + it('renders design-note-signed-out component', () => { + expect(findDesignNoteSignedOut().exists()).toBe(true); + }); + }); + }); }); diff --git a/spec/frontend/design_management/components/image_spec.js b/spec/frontend/design_management/components/image_spec.js index 765d902f9a6..ac3afc73c86 100644 --- a/spec/frontend/design_management/components/image_spec.js +++ b/spec/frontend/design_management/components/image_spec.js @@ -9,6 +9,8 @@ describe('Design management large image component', () => { wrapper = shallowMount(DesignImage, { propsData, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData(data); } diff --git a/spec/frontend/design_management/components/toolbar/design_navigation_spec.js b/spec/frontend/design_management/components/toolbar/design_navigation_spec.js index 1d9b9c002f9..6e0592984a2 100644 --- a/spec/frontend/design_management/components/toolbar/design_navigation_spec.js +++ b/spec/frontend/design_management/components/toolbar/design_navigation_spec.js @@ -42,6 +42,8 @@ describe('Design management pagination component', () => { }); it('renders navigation buttons', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ designCollection: { designs: [{ id: '1' }, { id: '2' }] }, }); @@ -53,6 +55,8 @@ describe('Design management pagination component', () => { describe('keyboard buttons navigation', () => { beforeEach(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ designCollection: { designs: [{ filename: '1' }, { filename: '2' }, { filename: '3' }] }, }); diff --git a/spec/frontend/design_management/components/toolbar/index_spec.js b/spec/frontend/design_management/components/toolbar/index_spec.js index 009ffe57744..cf872046f53 100644 --- a/spec/frontend/design_management/components/toolbar/index_spec.js +++ b/spec/frontend/design_management/components/toolbar/index_spec.js @@ -48,6 +48,8 @@ describe('Design management toolbar component', () => { }, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ permissions: { createDesign, diff --git a/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js index ebfe27eaa71..a4fb671ae13 100644 --- a/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js +++ b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js @@ -34,6 +34,8 @@ describe('Design management design version dropdown component', () => { stubs: { GlSprintf }, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ allVersions: maxVersions > -1 ? mockAllVersions.slice(0, maxVersions) : mockAllVersions, }); diff --git a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap index 3d04840b1f8..31b3117cb6c 100644 --- a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap @@ -70,6 +70,13 @@ exports[`Design management design index page renders design index 1`] = ` <!----> + <design-note-signed-out-stub + class="gl-mb-4" + isadddiscussion="true" + registerpath="" + signinpath="" + /> + <design-discussion-stub data-testid="unresolved-discussion" designid="gid::/gitlab/Design/1" @@ -77,6 +84,8 @@ exports[`Design management design index page renders design index 1`] = ` discussionwithopenform="" markdownpreviewpath="/project-path/preview_markdown?target_type=Issue" noteableid="gid::/gitlab/Design/1" + registerpath="" + signinpath="" /> <gl-button-stub @@ -126,6 +135,8 @@ exports[`Design management design index page renders design index 1`] = ` discussionwithopenform="" markdownpreviewpath="/project-path/preview_markdown?target_type=Issue" noteableid="gid::/gitlab/Design/1" + registerpath="" + signinpath="" /> </gl-collapse-stub> @@ -231,14 +242,14 @@ exports[`Design management design index page with error GlAlert is rendered in c participants="[object Object]" /> - <h2 - class="new-discussion-disclaimer gl-font-base gl-m-0 gl-mb-4" - data-testid="new-discussion-disclaimer" - > - - Click the image where you'd like to start a new discussion - - </h2> + <!----> + + <design-note-signed-out-stub + class="gl-mb-4" + isadddiscussion="true" + registerpath="" + signinpath="" + /> <!----> diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js index 6ce384b4869..98e2313e9f2 100644 --- a/spec/frontend/design_management/pages/design/index_spec.js +++ b/spec/frontend/design_management/pages/design/index_spec.js @@ -317,6 +317,8 @@ describe('Design management design index page', () => { describe('when no design exists for given version', () => { it('redirects to /designs', () => { createComponent({ loading: true }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ allVersions: mockAllVersions, }); diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index 427161a391b..dd0f7972553 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -91,6 +91,8 @@ const designToMove = { }; describe('Design management index page', () => { + const registerPath = '/users/sign_up?redirect_to_referer=yes'; + const signInPath = '/users/sign_in?redirect_to_referer=yes'; let mutate; let wrapper; let fakeApollo; @@ -164,6 +166,8 @@ describe('Design management index page', () => { provide: { projectPath: 'project-path', issueIid: '1', + registerPath, + signInPath, }, }); } @@ -186,6 +190,10 @@ describe('Design management index page', () => { apolloProvider: fakeApollo, router, stubs: { VueDraggable }, + provide: { + registerPath, + signInPath, + }, }); } @@ -204,6 +212,8 @@ describe('Design management index page', () => { it('renders error', async () => { createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: true }); await nextTick(); @@ -381,6 +391,8 @@ describe('Design management index page', () => { it('updates state appropriately after upload complete', async () => { createComponent({ stubs: { GlEmptyState } }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ filesToBeSaved: [{ name: 'test' }] }); wrapper.vm.onUploadDesignDone(designUploadMutationCreatedResponse); @@ -393,6 +405,8 @@ describe('Design management index page', () => { it('updates state appropriately after upload error', async () => { createComponent({ stubs: { GlEmptyState } }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ filesToBeSaved: [{ name: 'test' }] }); wrapper.vm.onUploadDesignError(); diff --git a/spec/frontend/diffs/components/image_diff_overlay_spec.js b/spec/frontend/diffs/components/image_diff_overlay_spec.js index 47b144b2387..8c1a8041f6c 100644 --- a/spec/frontend/diffs/components/image_diff_overlay_spec.js +++ b/spec/frontend/diffs/components/image_diff_overlay_spec.js @@ -6,8 +6,8 @@ import { imageDiffDiscussions } from '../mock_data/diff_discussions'; describe('Diffs image diff overlay component', () => { const dimensions = { - width: 100, - height: 200, + width: 99.9, + height: 199.5, }; let wrapper; let dispatch; @@ -38,7 +38,6 @@ describe('Diffs image diff overlay component', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); it('renders comment badges', () => { @@ -81,17 +80,21 @@ describe('Diffs image diff overlay component', () => { it('dispatches openDiffFileCommentForm when clicking overlay', () => { createComponent({ canComment: true }); - wrapper.find('.js-add-image-diff-note-button').trigger('click', { offsetX: 0, offsetY: 0 }); + wrapper.find('.js-add-image-diff-note-button').trigger('click', { offsetX: 1.2, offsetY: 3.8 }); expect(dispatch).toHaveBeenCalledWith('diffs/openDiffFileCommentForm', { fileHash: 'ABC', - x: 0, - y: 0, + x: 1, + y: 4, width: 100, height: 200, - xPercent: 0, - yPercent: 0, + xPercent: expect.any(Number), + yPercent: expect.any(Number), }); + + const { xPercent, yPercent } = dispatch.mock.calls[0][1]; + expect(xPercent).toBeCloseTo(0.6); + expect(yPercent).toBeCloseTo(1.9); }); describe('toggle discussion', () => { diff --git a/spec/frontend/editor/source_editor_spec.js b/spec/frontend/editor/source_editor_spec.js index bc53202c919..049cab3a83b 100644 --- a/spec/frontend/editor/source_editor_spec.js +++ b/spec/frontend/editor/source_editor_spec.js @@ -342,27 +342,30 @@ describe('Base editor', () => { describe('implementation', () => { let instance; - beforeEach(() => { - instance = editor.createInstance({ el: editorEl, blobPath, blobContent }); - }); it('correctly proxies value from the model', () => { + instance = editor.createInstance({ el: editorEl, blobPath, blobContent }); expect(instance.getValue()).toBe(blobContent); }); - it('emits the EDITOR_READY_EVENT event after setting up the instance', () => { + it('emits the EDITOR_READY_EVENT event passing the instance after setting it up', () => { jest.spyOn(monacoEditor, 'create').mockImplementation(() => { return { setModel: jest.fn(), onDidDispose: jest.fn(), layout: jest.fn(), + dispose: jest.fn(), }; }); - const eventSpy = jest.fn(); + let passedInstance; + const eventSpy = jest.fn().mockImplementation((ev) => { + passedInstance = ev.detail.instance; + }); editorEl.addEventListener(EDITOR_READY_EVENT, eventSpy); expect(eventSpy).not.toHaveBeenCalled(); - editor.createInstance({ el: editorEl }); + instance = editor.createInstance({ el: editorEl }); expect(eventSpy).toHaveBeenCalled(); + expect(passedInstance).toBe(instance); }); }); diff --git a/spec/frontend/emoji/components/category_spec.js b/spec/frontend/emoji/components/category_spec.js index afd36a1eb88..82dc0cdc250 100644 --- a/spec/frontend/emoji/components/category_spec.js +++ b/spec/frontend/emoji/components/category_spec.js @@ -26,6 +26,8 @@ describe('Emoji category component', () => { }); it('renders group', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ renderGroup: true }); expect(wrapper.find(EmojiGroup).attributes('rendergroup')).toBe('true'); diff --git a/spec/frontend/emoji/components/emoji_list_spec.js b/spec/frontend/emoji/components/emoji_list_spec.js index 9dc73ef191e..a72ba614d9f 100644 --- a/spec/frontend/emoji/components/emoji_list_spec.js +++ b/spec/frontend/emoji/components/emoji_list_spec.js @@ -28,6 +28,8 @@ async function factory(render, propsData = { searchValue: '' }) { await nextTick(); if (render) { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ render: true }); // Wait for component to render diff --git a/spec/frontend/environments/confirm_rollback_modal_spec.js b/spec/frontend/environments/confirm_rollback_modal_spec.js index b699f953945..b8dcb7c0d08 100644 --- a/spec/frontend/environments/confirm_rollback_modal_spec.js +++ b/spec/frontend/environments/confirm_rollback_modal_spec.js @@ -26,7 +26,7 @@ describe('Confirm Rollback Modal Component', () => { commit: { shortId: 'abc0123', }, - 'last?': true, + isLast: true, }, modalId: 'test', }; @@ -145,7 +145,7 @@ describe('Confirm Rollback Modal Component', () => { ...environment, lastDeployment: { ...environment.lastDeployment, - 'last?': false, + isLast: false, }, }, hasMultipleCommits, @@ -167,7 +167,7 @@ describe('Confirm Rollback Modal Component', () => { ...environment, lastDeployment: { ...environment.lastDeployment, - 'last?': false, + isLast: false, }, }, hasMultipleCommits, @@ -191,7 +191,7 @@ describe('Confirm Rollback Modal Component', () => { ...environment, lastDeployment: { ...environment.lastDeployment, - 'last?': true, + isLast: true, }, }, hasMultipleCommits, diff --git a/spec/frontend/environments/deployment_spec.js b/spec/frontend/environments/deployment_spec.js new file mode 100644 index 00000000000..37209bdc86c --- /dev/null +++ b/spec/frontend/environments/deployment_spec.js @@ -0,0 +1,29 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import Deployment from '~/environments/components/deployment.vue'; +import DeploymentStatusBadge from '~/environments/components/deployment_status_badge.vue'; +import { resolvedEnvironment } from './graphql/mock_data'; + +describe('~/environments/components/deployment.vue', () => { + let wrapper; + + const createWrapper = ({ propsData = {} } = {}) => + mountExtended(Deployment, { + propsData: { + deployment: resolvedEnvironment.lastDeployment, + ...propsData, + }, + }); + + afterEach(() => { + wrapper?.destroy(); + }); + + describe('status', () => { + it('should pass the deployable status to the badge', () => { + wrapper = createWrapper(); + expect(wrapper.findComponent(DeploymentStatusBadge).props('status')).toBe( + resolvedEnvironment.lastDeployment.status, + ); + }); + }); +}); diff --git a/spec/frontend/environments/deployment_status_badge_spec.js b/spec/frontend/environments/deployment_status_badge_spec.js new file mode 100644 index 00000000000..02aae57396a --- /dev/null +++ b/spec/frontend/environments/deployment_status_badge_spec.js @@ -0,0 +1,42 @@ +import { GlBadge } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { s__ } from '~/locale'; +import DeploymentStatusBadge from '~/environments/components/deployment_status_badge.vue'; + +describe('~/environments/components/deployment_status_badge.vue', () => { + let wrapper; + + const createWrapper = ({ propsData = {} } = {}) => + mountExtended(DeploymentStatusBadge, { + propsData, + }); + + describe.each` + status | text | variant | icon + ${'created'} | ${s__('Deployment|Created')} | ${'neutral'} | ${'status_created'} + ${'running'} | ${s__('Deployment|Running')} | ${'info'} | ${'status_running'} + ${'success'} | ${s__('Deployment|Success')} | ${'success'} | ${'status_success'} + ${'failed'} | ${s__('Deployment|Failed')} | ${'danger'} | ${'status_failed'} + ${'canceled'} | ${s__('Deployment|Cancelled')} | ${'neutral'} | ${'status_canceled'} + ${'skipped'} | ${s__('Deployment|Skipped')} | ${'neutral'} | ${'status_skipped'} + ${'blocked'} | ${s__('Deployment|Waiting')} | ${'neutral'} | ${'status_manual'} + `('$status', ({ status, text, variant, icon }) => { + let badge; + + beforeEach(() => { + wrapper = createWrapper({ propsData: { status } }); + badge = wrapper.findComponent(GlBadge); + }); + + it(`sets the text to ${text}`, () => { + expect(wrapper.text()).toBe(text); + }); + + it(`sets the variant to ${variant}`, () => { + expect(badge.props('variant')).toBe(variant); + }); + it(`sets the icon to ${icon}`, () => { + expect(badge.props('icon')).toBe(icon); + }); + }); +}); diff --git a/spec/frontend/environments/environment_actions_spec.js b/spec/frontend/environments/environment_actions_spec.js index db78a6b0cdd..1b68a692db8 100644 --- a/spec/frontend/environments/environment_actions_spec.js +++ b/spec/frontend/environments/environment_actions_spec.js @@ -1,9 +1,13 @@ import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlIcon } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { TEST_HOST } from 'helpers/test_constants'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import EnvironmentActions from '~/environments/components/environment_actions.vue'; import eventHub from '~/environments/event_hub'; +import actionMutation from '~/environments/graphql/mutations/action.mutation.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; const scheduledJobAction = { name: 'scheduled action', @@ -25,12 +29,13 @@ describe('EnvironmentActions Component', () => { const findEnvironmentActionsButton = () => wrapper.find('[data-testid="environment-actions-button"]'); - function createComponent(props, { mountFn = shallowMount } = {}) { + function createComponent(props, { mountFn = shallowMount, options = {} } = {}) { wrapper = mountFn(EnvironmentActions, { propsData: { actions: [], ...props }, directives: { GlTooltip: createMockDirective(), }, + ...options, }); } @@ -150,4 +155,32 @@ describe('EnvironmentActions Component', () => { expect(findDropdownItem(expiredJobAction).text()).toContain('00:00:00'); }); }); + + describe('graphql', () => { + Vue.use(VueApollo); + + const action = { + name: 'bar', + play_path: 'https://gitlab.com/play', + }; + + let mockApollo; + + beforeEach(() => { + mockApollo = createMockApollo(); + createComponent( + { actions: [action], graphql: true }, + { options: { apolloProvider: mockApollo } }, + ); + }); + + it('should trigger a graphql mutation on click', () => { + jest.spyOn(mockApollo.defaultClient, 'mutate'); + findDropdownItem(action).vm.$emit('click'); + expect(mockApollo.defaultClient.mutate).toHaveBeenCalledWith({ + mutation: actionMutation, + variables: { action }, + }); + }); + }); }); diff --git a/spec/frontend/environments/environment_stop_spec.js b/spec/frontend/environments/environment_stop_spec.js index dff444b79f3..358abca2f77 100644 --- a/spec/frontend/environments/environment_stop_spec.js +++ b/spec/frontend/environments/environment_stop_spec.js @@ -1,38 +1,80 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import $ from 'jquery'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import setEnvironmentToStopMutation from '~/environments/graphql/mutations/set_environment_to_stop.mutation.graphql'; +import isEnvironmentStoppingQuery from '~/environments/graphql/queries/is_environment_stopping.query.graphql'; import StopComponent from '~/environments/components/environment_stop.vue'; import eventHub from '~/environments/event_hub'; - -$.fn.tooltip = () => {}; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { resolvedEnvironment } from './graphql/mock_data'; describe('Stop Component', () => { let wrapper; - const createWrapper = () => { + const createWrapper = (props = {}, options = {}) => { wrapper = shallowMount(StopComponent, { propsData: { environment: {}, + ...props, }, + ...options, }); }; const findButton = () => wrapper.find(GlButton); - beforeEach(() => { - jest.spyOn(window, 'confirm'); + describe('eventHub', () => { + beforeEach(() => { + createWrapper(); + }); - createWrapper(); - }); + it('should render a button to stop the environment', () => { + expect(findButton().exists()).toBe(true); + expect(wrapper.attributes('title')).toEqual('Stop environment'); + }); - it('should render a button to stop the environment', () => { - expect(findButton().exists()).toBe(true); - expect(wrapper.attributes('title')).toEqual('Stop environment'); + it('emits requestStopEnvironment in the event hub when button is clicked', () => { + jest.spyOn(eventHub, '$emit'); + findButton().vm.$emit('click'); + expect(eventHub.$emit).toHaveBeenCalledWith('requestStopEnvironment', wrapper.vm.environment); + }); }); - it('emits requestStopEnvironment in the event hub when button is clicked', () => { - jest.spyOn(eventHub, '$emit'); - findButton().vm.$emit('click'); - expect(eventHub.$emit).toHaveBeenCalledWith('requestStopEnvironment', wrapper.vm.environment); + describe('graphql', () => { + Vue.use(VueApollo); + let mockApollo; + + beforeEach(() => { + mockApollo = createMockApollo(); + mockApollo.clients.defaultClient.writeQuery({ + query: isEnvironmentStoppingQuery, + variables: { environment: resolvedEnvironment }, + data: { isEnvironmentStopping: true }, + }); + + createWrapper( + { graphql: true, environment: resolvedEnvironment }, + { apolloProvider: mockApollo }, + ); + }); + + it('should render a button to stop the environment', () => { + expect(findButton().exists()).toBe(true); + expect(wrapper.attributes('title')).toEqual('Stop environment'); + }); + + it('sets the environment to stop on click', () => { + jest.spyOn(mockApollo.defaultClient, 'mutate'); + findButton().vm.$emit('click'); + expect(mockApollo.defaultClient.mutate).toHaveBeenCalledWith({ + mutation: setEnvironmentToStopMutation, + variables: { environment: resolvedEnvironment }, + }); + }); + + it('should show a loading icon if the environment is currently stopping', async () => { + expect(findButton().props('loading')).toBe(true); + }); }); }); diff --git a/spec/frontend/environments/graphql/mock_data.js b/spec/frontend/environments/graphql/mock_data.js index e75d3ac0321..fce30973547 100644 --- a/spec/frontend/environments/graphql/mock_data.js +++ b/spec/frontend/environments/graphql/mock_data.js @@ -477,7 +477,141 @@ export const resolvedEnvironment = { externalUrl: 'https://example.org', environmentType: 'review', nameWithoutType: 'hello', - lastDeployment: null, + lastDeployment: { + id: 78, + iid: 24, + sha: 'f3ba6dd84f8f891373e9b869135622b954852db1', + ref: { name: 'main', refPath: '/h5bp/html5-boilerplate/-/tree/main' }, + status: 'success', + createdAt: '2022-01-07T15:47:27.415Z', + deployedAt: '2022-01-07T15:47:32.450Z', + tag: false, + isLast: true, + user: { + id: 1, + username: 'root', + name: 'Administrator', + state: 'active', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + webUrl: 'http://gck.test:3000/root', + showStatus: false, + path: '/root', + }, + deployable: { + id: 1014, + name: 'deploy-prod', + started: '2022-01-07T15:47:31.037Z', + complete: true, + archived: false, + buildPath: '/h5bp/html5-boilerplate/-/jobs/1014', + retryPath: '/h5bp/html5-boilerplate/-/jobs/1014/retry', + playable: false, + scheduled: false, + createdAt: '2022-01-07T15:47:27.404Z', + updatedAt: '2022-01-07T15:47:32.341Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/h5bp/html5-boilerplate/-/jobs/1014', + illustration: { + image: + '/assets/illustrations/skipped-job_empty-29a8a37d8a61d1b6f68cf3484f9024e53cd6eb95e28eae3554f8011a1146bf27.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/h5bp/html5-boilerplate/-/jobs/1014/retry', + method: 'post', + buttonTitle: 'Retry this job', + }, + }, + }, + commit: { + id: 'f3ba6dd84f8f891373e9b869135622b954852db1', + shortId: 'f3ba6dd8', + createdAt: '2022-01-07T15:47:26.000+00:00', + parentIds: ['3213b6ac17afab99be37d5d38f38c6c8407387cc'], + title: 'Update .gitlab-ci.yml file', + message: 'Update .gitlab-ci.yml file', + authorName: 'Administrator', + authorEmail: 'admin@example.com', + authoredDate: '2022-01-07T15:47:26.000+00:00', + committerName: 'Administrator', + committerEmail: 'admin@example.com', + committedDate: '2022-01-07T15:47:26.000+00:00', + trailers: {}, + webUrl: + 'http://gck.test:3000/h5bp/html5-boilerplate/-/commit/f3ba6dd84f8f891373e9b869135622b954852db1', + author: { + id: 1, + username: 'root', + name: 'Administrator', + state: 'active', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + webUrl: 'http://gck.test:3000/root', + showStatus: false, + path: '/root', + }, + authorGravatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + commitUrl: + 'http://gck.test:3000/h5bp/html5-boilerplate/-/commit/f3ba6dd84f8f891373e9b869135622b954852db1', + commitPath: '/h5bp/html5-boilerplate/-/commit/f3ba6dd84f8f891373e9b869135622b954852db1', + }, + manualActions: [ + { + id: 1015, + name: 'deploy-staging', + started: null, + complete: false, + archived: false, + buildPath: '/h5bp/html5-boilerplate/-/jobs/1015', + playPath: '/h5bp/html5-boilerplate/-/jobs/1015/play', + playable: true, + scheduled: false, + createdAt: '2022-01-07T15:47:27.422Z', + updatedAt: '2022-01-07T15:47:28.557Z', + status: { + icon: 'status_manual', + text: 'manual', + label: 'manual play action', + group: 'manual', + tooltip: 'manual action', + hasDetails: true, + detailsPath: '/h5bp/html5-boilerplate/-/jobs/1015', + illustration: { + image: + '/assets/illustrations/manual_action-c55aee2c5f9ebe9f72751480af8bb307be1a6f35552f344cc6d1bf979d3422f6.svg', + size: 'svg-394', + title: 'This job requires a manual action', + content: + 'This job requires manual intervention to start. Before starting this job, you can add variables below for last-minute configuration changes.', + }, + favicon: + '/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', + action: { + icon: 'play', + title: 'Play', + path: '/h5bp/html5-boilerplate/-/jobs/1015/play', + method: 'post', + buttonTitle: 'Trigger this manual action', + }, + }, + }, + ], + scheduledActions: [], + cluster: null, + }, hasStopAction: false, rolloutStatus: null, environmentPath: '/h5bp/html5-boilerplate/-/environments/41', diff --git a/spec/frontend/environments/graphql/resolvers_spec.js b/spec/frontend/environments/graphql/resolvers_spec.js index d8d26b74504..6b53dc24f0f 100644 --- a/spec/frontend/environments/graphql/resolvers_spec.js +++ b/spec/frontend/environments/graphql/resolvers_spec.js @@ -1,8 +1,10 @@ import MockAdapter from 'axios-mock-adapter'; +import { s__ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import { resolvers } from '~/environments/graphql/resolvers'; import environmentToRollback from '~/environments/graphql/queries/environment_to_rollback.query.graphql'; import environmentToDelete from '~/environments/graphql/queries/environment_to_delete.query.graphql'; +import environmentToStopQuery from '~/environments/graphql/queries/environment_to_stop.query.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import pollIntervalQuery from '~/environments/graphql/queries/poll_interval.query.graphql'; import pageInfoQuery from '~/environments/graphql/queries/page_info.query.graphql'; @@ -210,4 +212,36 @@ describe('~/frontend/environments/graphql/resolvers', () => { }); }); }); + describe('setEnvironmentToStop', () => { + it('should write the given environment to the cache', () => { + localState.client.writeQuery = jest.fn(); + mockResolvers.Mutation.setEnvironmentToStop( + null, + { environment: resolvedEnvironment }, + localState, + ); + + expect(localState.client.writeQuery).toHaveBeenCalledWith({ + query: environmentToStopQuery, + data: { environmentToStop: resolvedEnvironment }, + }); + }); + }); + describe('action', () => { + it('should POST to the given path', async () => { + mock.onPost(ENDPOINT).reply(200); + const errors = await mockResolvers.Mutation.action(null, { action: { playPath: ENDPOINT } }); + + expect(errors).toEqual({ __typename: 'LocalEnvironmentErrors', errors: [] }); + }); + it('should return a nice error message on fail', async () => { + mock.onPost(ENDPOINT).reply(500); + const errors = await mockResolvers.Mutation.action(null, { action: { playPath: ENDPOINT } }); + + expect(errors).toEqual({ + __typename: 'LocalEnvironmentErrors', + errors: [s__('Environments|An error occurred while making the request.')], + }); + }); + }); }); diff --git a/spec/frontend/environments/new_environment_folder_spec.js b/spec/frontend/environments/new_environment_folder_spec.js index 27d27d5869a..6823c88a5a1 100644 --- a/spec/frontend/environments/new_environment_folder_spec.js +++ b/spec/frontend/environments/new_environment_folder_spec.js @@ -1,10 +1,13 @@ import VueApollo from 'vue-apollo'; -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import { GlCollapse, GlIcon } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { stubTransition } from 'helpers/stub_transition'; import { __, s__ } from '~/locale'; import EnvironmentsFolder from '~/environments/components/new_environment_folder.vue'; +import EnvironmentItem from '~/environments/components/new_environment_item.vue'; import { resolvedEnvironmentsApp, resolvedFolder } from './graphql/mock_data'; Vue.use(VueApollo); @@ -25,13 +28,20 @@ describe('~/environments/components/new_environments_folder.vue', () => { }; const createWrapper = (propsData, apolloProvider) => - mountExtended(EnvironmentsFolder, { apolloProvider, propsData }); + mountExtended(EnvironmentsFolder, { + apolloProvider, + propsData, + stubs: { transition: stubTransition() }, + }); - beforeEach(() => { + beforeEach(async () => { environmentFolderMock = jest.fn(); [nestedEnvironment] = resolvedEnvironmentsApp.environments; environmentFolderMock.mockReturnValue(resolvedFolder); wrapper = createWrapper({ nestedEnvironment }, createApolloProvider()); + + await nextTick(); + await waitForPromises(); folderName = wrapper.findByText(nestedEnvironment.name); button = wrapper.findByRole('button', { name: __('Expand') }); }); @@ -57,7 +67,8 @@ describe('~/environments/components/new_environments_folder.vue', () => { const link = findLink(); expect(collapse.attributes('visible')).toBeUndefined(); - expect(icons.wrappers.map((i) => i.props('name'))).toEqual(['angle-right', 'folder-o']); + const iconNames = icons.wrappers.map((i) => i.props('name')).slice(0, 2); + expect(iconNames).toEqual(['angle-right', 'folder-o']); expect(folderName.classes('gl-font-weight-bold')).toBe(false); expect(link.exists()).toBe(false); }); @@ -68,10 +79,21 @@ describe('~/environments/components/new_environments_folder.vue', () => { const link = findLink(); expect(button.attributes('aria-label')).toBe(__('Collapse')); - expect(collapse.attributes('visible')).toBe('true'); - expect(icons.wrappers.map((i) => i.props('name'))).toEqual(['angle-down', 'folder-open']); + expect(collapse.attributes('visible')).toBe('visible'); + const iconNames = icons.wrappers.map((i) => i.props('name')).slice(0, 2); + expect(iconNames).toEqual(['angle-down', 'folder-open']); expect(folderName.classes('gl-font-weight-bold')).toBe(true); expect(link.attributes('href')).toBe(nestedEnvironment.latest.folderPath); }); + + it('displays all environments when opened', async () => { + await button.trigger('click'); + + const names = resolvedFolder.environments.map((e) => + expect.stringMatching(e.nameWithoutType), + ); + const environments = wrapper.findAllComponents(EnvironmentItem).wrappers.map((w) => w.text()); + expect(environments).toEqual(expect.arrayContaining(names)); + }); }); }); diff --git a/spec/frontend/environments/new_environment_item_spec.js b/spec/frontend/environments/new_environment_item_spec.js new file mode 100644 index 00000000000..244aef5c43b --- /dev/null +++ b/spec/frontend/environments/new_environment_item_spec.js @@ -0,0 +1,341 @@ +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; +import { GlCollapse, GlIcon } from '@gitlab/ui'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { stubTransition } from 'helpers/stub_transition'; +import { __, s__ } from '~/locale'; +import EnvironmentItem from '~/environments/components/new_environment_item.vue'; +import Deployment from '~/environments/components/deployment.vue'; +import { resolvedEnvironment } from './graphql/mock_data'; + +Vue.use(VueApollo); + +describe('~/environments/components/new_environment_item.vue', () => { + let wrapper; + + const createApolloProvider = () => { + return createMockApollo(); + }; + + const createWrapper = ({ propsData = {}, apolloProvider } = {}) => + mountExtended(EnvironmentItem, { + apolloProvider, + propsData: { environment: resolvedEnvironment, ...propsData }, + stubs: { transition: stubTransition() }, + }); + + const findDeployment = () => wrapper.findComponent(Deployment); + + afterEach(() => { + wrapper?.destroy(); + }); + + it('displays the name when not in a folder', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const name = wrapper.findByRole('link', { name: resolvedEnvironment.name }); + expect(name.exists()).toBe(true); + }); + + it('displays the name minus the folder prefix when in a folder', () => { + wrapper = createWrapper({ + propsData: { inFolder: true }, + apolloProvider: createApolloProvider(), + }); + + const name = wrapper.findByRole('link', { name: resolvedEnvironment.nameWithoutType }); + expect(name.exists()).toBe(true); + }); + + it('truncates the name if it is very long', () => { + const environment = { + ...resolvedEnvironment, + name: + 'this is a really long name that should be truncated because otherwise it would look strange in the UI', + }; + wrapper = createWrapper({ propsData: { environment }, apolloProvider: createApolloProvider() }); + + const name = wrapper.findByRole('link', { + name: (text) => environment.name.startsWith(text.slice(0, -1)), + }); + expect(name.exists()).toBe(true); + expect(name.text()).toHaveLength(80); + }); + + describe('url', () => { + it('shows a link for the url if one is present', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const url = wrapper.findByRole('link', { name: s__('Environments|Open live environment') }); + + expect(url.attributes('href')).toEqual(resolvedEnvironment.externalUrl); + }); + + it('does not show a link for the url if one is missing', () => { + wrapper = createWrapper({ + propsData: { environment: { ...resolvedEnvironment, externalUrl: '' } }, + apolloProvider: createApolloProvider(), + }); + + const url = wrapper.findByRole('link', { name: s__('Environments|Open live environment') }); + + expect(url.exists()).toBe(false); + }); + }); + + describe('actions', () => { + it('shows a dropdown if there are actions to perform', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const actions = wrapper.findByRole('button', { name: __('Deploy to...') }); + + expect(actions.exists()).toBe(true); + }); + + it('does not show a dropdown if there are no actions to perform', () => { + wrapper = createWrapper({ + propsData: { + environment: { + ...resolvedEnvironment, + lastDeployment: null, + }, + apolloProvider: createApolloProvider(), + }, + }); + + const actions = wrapper.findByRole('button', { name: __('Deploy to...') }); + + expect(actions.exists()).toBe(false); + }); + + it('passes all the actions down to the action component', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const action = wrapper.findByRole('menuitem', { name: 'deploy-staging' }); + + expect(action.exists()).toBe(true); + }); + }); + + describe('stop', () => { + it('shows a buton to stop the environment if the environment is available', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const stop = wrapper.findByRole('button', { name: s__('Environments|Stop environment') }); + + expect(stop.exists()).toBe(true); + }); + + it('does not show a buton to stop the environment if the environment is stopped', () => { + wrapper = createWrapper({ + propsData: { environment: { ...resolvedEnvironment, canStop: false } }, + apolloProvider: createApolloProvider(), + }); + + const stop = wrapper.findByRole('button', { name: s__('Environments|Stop environment') }); + + expect(stop.exists()).toBe(false); + }); + }); + + describe('rollback', () => { + it('shows the option to rollback/re-deploy if available', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const rollback = wrapper.findByRole('menuitem', { + name: s__('Environments|Re-deploy to environment'), + }); + + expect(rollback.exists()).toBe(true); + }); + + it('does not show the option to rollback/re-deploy if not available', () => { + wrapper = createWrapper({ + propsData: { environment: { ...resolvedEnvironment, lastDeployment: null } }, + apolloProvider: createApolloProvider(), + }); + + const rollback = wrapper.findByRole('menuitem', { + name: s__('Environments|Re-deploy to environment'), + }); + + expect(rollback.exists()).toBe(false); + }); + }); + + describe('pin', () => { + it('shows the option to pin the environment if there is an autostop date', () => { + wrapper = createWrapper({ + propsData: { + environment: { ...resolvedEnvironment, autoStopAt: new Date(Date.now() + 100000) }, + }, + apolloProvider: createApolloProvider(), + }); + + const rollback = wrapper.findByRole('menuitem', { name: __('Prevent auto-stopping') }); + + expect(rollback.exists()).toBe(true); + }); + + it('does not show the option to pin the environment if there is no autostop date', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const rollback = wrapper.findByRole('menuitem', { name: __('Prevent auto-stopping') }); + + expect(rollback.exists()).toBe(false); + }); + }); + + describe('monitoring', () => { + it('shows the link to monitoring if metrics are set up', () => { + wrapper = createWrapper({ + propsData: { environment: { ...resolvedEnvironment, metricsPath: '/metrics' } }, + apolloProvider: createApolloProvider(), + }); + + const rollback = wrapper.findByRole('menuitem', { name: __('Monitoring') }); + + expect(rollback.exists()).toBe(true); + }); + + it('does not show the link to monitoring if metrics are not set up', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const rollback = wrapper.findByRole('menuitem', { name: __('Monitoring') }); + + expect(rollback.exists()).toBe(false); + }); + }); + describe('terminal', () => { + it('shows the link to the terminal if set up', () => { + wrapper = createWrapper({ + propsData: { environment: { ...resolvedEnvironment, terminalPath: '/terminal' } }, + apolloProvider: createApolloProvider(), + }); + + const rollback = wrapper.findByRole('menuitem', { name: __('Terminal') }); + + expect(rollback.exists()).toBe(true); + }); + + it('does not show the link to the terminal if not set up', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const rollback = wrapper.findByRole('menuitem', { name: __('Terminal') }); + + expect(rollback.exists()).toBe(false); + }); + }); + + describe('delete', () => { + it('shows the button to delete the environment if possible', () => { + wrapper = createWrapper({ + propsData: { + environment: { ...resolvedEnvironment, canDelete: true, deletePath: '/terminal' }, + }, + apolloProvider: createApolloProvider(), + }); + + const rollback = wrapper.findByRole('menuitem', { + name: s__('Environments|Delete environment'), + }); + + expect(rollback.exists()).toBe(true); + }); + + it('does not show the button to delete the environment if not possible', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const rollback = wrapper.findByRole('menuitem', { + name: s__('Environments|Delete environment'), + }); + + expect(rollback.exists()).toBe(false); + }); + }); + + describe('collapse', () => { + let icon; + let collapse; + let button; + let environmentName; + + beforeEach(() => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + collapse = wrapper.findComponent(GlCollapse); + icon = wrapper.findComponent(GlIcon); + button = wrapper.findByRole('button', { name: __('Expand') }); + environmentName = wrapper.findByText(resolvedEnvironment.name); + }); + + it('is collapsed by default', () => { + expect(collapse.attributes('visible')).toBeUndefined(); + expect(icon.props('name')).toEqual('angle-right'); + expect(environmentName.classes('gl-font-weight-bold')).toBe(false); + }); + + it('opens on click', async () => { + expect(findDeployment().isVisible()).toBe(false); + + await button.trigger('click'); + + expect(button.attributes('aria-label')).toBe(__('Collapse')); + expect(collapse.attributes('visible')).toBe('visible'); + expect(icon.props('name')).toEqual('angle-down'); + expect(environmentName.classes('gl-font-weight-bold')).toBe(true); + expect(findDeployment().isVisible()).toBe(true); + }); + }); + describe('last deployment', () => { + it('should pass the last deployment to the deployment component when it exists', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const deployment = findDeployment(); + expect(deployment.props('deployment')).toEqual(resolvedEnvironment.lastDeployment); + }); + it('should not show the last deployment when it is missing', () => { + const environment = { + ...resolvedEnvironment, + lastDeployment: null, + }; + + wrapper = createWrapper({ + propsData: { environment }, + apolloProvider: createApolloProvider(), + }); + + const deployment = findDeployment(); + expect(deployment.exists()).toBe(false); + }); + }); + + describe('upcoming deployment', () => { + it('should pass the upcoming deployment to the deployment component when it exists', () => { + const upcomingDeployment = resolvedEnvironment.lastDeployment; + const environment = { ...resolvedEnvironment, lastDeployment: null, upcomingDeployment }; + wrapper = createWrapper({ + propsData: { environment }, + apolloProvider: createApolloProvider(), + }); + + const deployment = findDeployment(); + expect(deployment.props('deployment')).toEqual(upcomingDeployment); + }); + it('should not show the upcoming deployment when it is missing', () => { + const environment = { + ...resolvedEnvironment, + lastDeployment: null, + upcomingDeployment: null, + }; + + wrapper = createWrapper({ + propsData: { environment }, + apolloProvider: createApolloProvider(), + }); + + const deployment = findDeployment(); + expect(deployment.exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/environments/new_environments_app_spec.js b/spec/frontend/environments/new_environments_app_spec.js index 1e9bd4d64c9..c9eccc26694 100644 --- a/spec/frontend/environments/new_environments_app_spec.js +++ b/spec/frontend/environments/new_environments_app_spec.js @@ -8,7 +8,9 @@ import setWindowLocation from 'helpers/set_window_location_helper'; import { sprintf, __, s__ } from '~/locale'; import EnvironmentsApp from '~/environments/components/new_environments_app.vue'; import EnvironmentsFolder from '~/environments/components/new_environment_folder.vue'; -import { resolvedEnvironmentsApp, resolvedFolder } from './graphql/mock_data'; +import EnvironmentsItem from '~/environments/components/new_environment_item.vue'; +import StopEnvironmentModal from '~/environments/components/stop_environment_modal.vue'; +import { resolvedEnvironmentsApp, resolvedFolder, resolvedEnvironment } from './graphql/mock_data'; Vue.use(VueApollo); @@ -17,6 +19,7 @@ describe('~/environments/components/new_environments_app.vue', () => { let environmentAppMock; let environmentFolderMock; let paginationMock; + let environmentToStopMock; const createApolloProvider = () => { const mockResolvers = { @@ -24,6 +27,7 @@ describe('~/environments/components/new_environments_app.vue', () => { environmentApp: environmentAppMock, folder: environmentFolderMock, pageInfo: paginationMock, + environmentToStop: environmentToStopMock, }, }; @@ -45,6 +49,7 @@ describe('~/environments/components/new_environments_app.vue', () => { provide = {}, environmentsApp, folder, + environmentToStop = {}, pageInfo = { total: 20, perPage: 5, @@ -58,6 +63,7 @@ describe('~/environments/components/new_environments_app.vue', () => { environmentAppMock.mockReturnValue(environmentsApp); environmentFolderMock.mockReturnValue(folder); paginationMock.mockReturnValue(pageInfo); + environmentToStopMock.mockReturnValue(environmentToStop); const apolloProvider = createApolloProvider(); wrapper = createWrapper({ apolloProvider, provide }); @@ -68,6 +74,7 @@ describe('~/environments/components/new_environments_app.vue', () => { beforeEach(() => { environmentAppMock = jest.fn(); environmentFolderMock = jest.fn(); + environmentToStopMock = jest.fn(); paginationMock = jest.fn(); }); @@ -87,6 +94,18 @@ describe('~/environments/components/new_environments_app.vue', () => { expect(text).not.toContainEqual(expect.stringMatching('production')); }); + it('should show all the environments that are fetched', async () => { + await createWrapperWithMocked({ + environmentsApp: resolvedEnvironmentsApp, + folder: resolvedFolder, + }); + + const text = wrapper.findAllComponents(EnvironmentsItem).wrappers.map((w) => w.text()); + + expect(text).not.toContainEqual(expect.stringMatching('review')); + expect(text).toContainEqual(expect.stringMatching('production')); + }); + it('should show a button to create a new environment', async () => { await createWrapperWithMocked({ environmentsApp: resolvedEnvironmentsApp, @@ -168,13 +187,27 @@ describe('~/environments/components/new_environments_app.vue', () => { expect(environmentAppMock).toHaveBeenCalledWith( expect.anything(), - expect.objectContaining({ scope: 'stopped' }), + expect.objectContaining({ scope: 'stopped', page: 1 }), expect.anything(), expect.anything(), ); }); }); + describe('modals', () => { + it('should pass the environment to stop to the stop environment modal', async () => { + await createWrapperWithMocked({ + environmentsApp: resolvedEnvironmentsApp, + folder: resolvedFolder, + environmentToStop: resolvedEnvironment, + }); + + const modal = wrapper.findComponent(StopEnvironmentModal); + + expect(modal.props('environment')).toMatchObject(resolvedEnvironment); + }); + }); + describe('pagination', () => { it('should sync page from query params on load', async () => { await createWrapperWithMocked({ diff --git a/spec/frontend/error_tracking/components/error_details_spec.js b/spec/frontend/error_tracking/components/error_details_spec.js index 4e459d800e8..77f51193258 100644 --- a/spec/frontend/error_tracking/components/error_details_spec.js +++ b/spec/frontend/error_tracking/components/error_details_spec.js @@ -173,6 +173,8 @@ describe('ErrorDetails', () => { beforeEach(() => { mocks.$apollo.queries.error.loading = false; mountComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { id: 'gid://gitlab/Gitlab::ErrorTracking::DetailedError/129381', @@ -203,6 +205,8 @@ describe('ErrorDetails', () => { const culprit = '<script>console.log("surprise!")</script>'; beforeEach(() => { store.state.details.loadingStacktrace = false; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { culprit, @@ -222,6 +226,8 @@ describe('ErrorDetails', () => { describe('Badges', () => { it('should show language and error level badges', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { tags: { level: 'error', logger: 'ruby' }, @@ -233,6 +239,8 @@ describe('ErrorDetails', () => { }); it('should NOT show the badge if the tag is not present', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { tags: { level: 'error' }, @@ -246,6 +254,8 @@ describe('ErrorDetails', () => { it.each(Object.keys(severityLevel))( 'should set correct severity level variant for %s badge', (level) => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { tags: { level: severityLevel[level] }, @@ -260,6 +270,8 @@ describe('ErrorDetails', () => { ); it('should fallback for ERROR severityLevelVariant when severityLevel is unknown', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { tags: { level: 'someNewErrorLevel' }, @@ -408,6 +420,8 @@ describe('ErrorDetails', () => { it('should show alert with closed issueId', () => { const closedIssueId = 123; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isAlertVisible: true, closedIssueId, @@ -429,6 +443,8 @@ describe('ErrorDetails', () => { describe('is present', () => { beforeEach(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { gitlabIssuePath, @@ -451,6 +467,8 @@ describe('ErrorDetails', () => { describe('is not present', () => { beforeEach(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { gitlabIssuePath: null, @@ -480,6 +498,8 @@ describe('ErrorDetails', () => { it('should display a link', () => { mocks.$apollo.queries.error.loading = false; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { gitlabCommit, @@ -493,6 +513,8 @@ describe('ErrorDetails', () => { it('should not display a link', () => { mocks.$apollo.queries.error.loading = false; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { gitlabCommit: null, @@ -519,6 +541,8 @@ describe('ErrorDetails', () => { it('should display links to Sentry', async () => { mocks.$apollo.queries.error.loading = false; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ error: { firstReleaseVersion, @@ -535,6 +559,8 @@ describe('ErrorDetails', () => { it('should display links to GitLab when integrated', async () => { mocks.$apollo.queries.error.loading = false; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ error: { firstReleaseVersion, @@ -557,6 +583,8 @@ describe('ErrorDetails', () => { jest.spyOn(Tracking, 'event'); mocks.$apollo.queries.error.loading = false; mountComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { externalUrl }, }); 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 c0c542ae587..74d5731bbea 100644 --- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js +++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js @@ -396,6 +396,8 @@ describe('ErrorTrackingList', () => { GlPagination: false, }, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ pageValue: 2 }); return wrapper.vm.$nextTick(); }); diff --git a/spec/frontend/fixtures/blob.rb b/spec/frontend/fixtures/blob.rb index bfdeee0881b..35a7ff4eb07 100644 --- a/spec/frontend/fixtures/blob.rb +++ b/spec/frontend/fixtures/blob.rb @@ -12,6 +12,7 @@ RSpec.describe Projects::BlobController, '(JavaScript fixtures)', type: :control render_views before do + stub_feature_flags(refactor_blob_viewer: false) # This fixture is only used by the legacy (non-refactored) blob viewer sign_in(user) allow(SecureRandom).to receive(:hex).and_return('securerandomhex:thereisnospoon') end diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb index fa150fbf57c..36e6cf72750 100644 --- a/spec/frontend/fixtures/runner.rb +++ b/spec/frontend/fixtures/runner.rb @@ -24,80 +24,109 @@ RSpec.describe 'Runner (JavaScript fixtures)' do remove_repository(project) end - describe GraphQL::Query, type: :request do - get_runners_query_name = 'get_runners.query.graphql' - + describe do before do sign_in(admin) enable_admin_mode!(admin) end - let_it_be(:query) do - get_graphql_query_as_string("#{query_path}#{get_runners_query_name}") - end + describe GraphQL::Query, type: :request do + get_runners_query_name = 'get_runners.query.graphql' - it "#{fixtures_path}#{get_runners_query_name}.json" do - post_graphql(query, current_user: admin, variables: {}) + let_it_be(:query) do + get_graphql_query_as_string("#{query_path}#{get_runners_query_name}") + end - expect_graphql_errors_to_be_empty - end + it "#{fixtures_path}#{get_runners_query_name}.json" do + post_graphql(query, current_user: admin, variables: {}) - it "#{fixtures_path}#{get_runners_query_name}.paginated.json" do - post_graphql(query, current_user: admin, variables: { first: 2 }) + expect_graphql_errors_to_be_empty + end - expect_graphql_errors_to_be_empty + it "#{fixtures_path}#{get_runners_query_name}.paginated.json" do + post_graphql(query, current_user: admin, variables: { first: 2 }) + + expect_graphql_errors_to_be_empty + end end - end - describe GraphQL::Query, type: :request do - get_runner_query_name = 'get_runner.query.graphql' + describe GraphQL::Query, type: :request do + get_runners_count_query_name = 'get_runners_count.query.graphql' - before do - sign_in(admin) - enable_admin_mode!(admin) - end + let_it_be(:query) do + get_graphql_query_as_string("#{query_path}#{get_runners_count_query_name}") + end + + it "#{fixtures_path}#{get_runners_count_query_name}.json" do + post_graphql(query, current_user: admin, variables: {}) - let_it_be(:query) do - get_graphql_query_as_string("#{query_path}#{get_runner_query_name}") + expect_graphql_errors_to_be_empty + end end - it "#{fixtures_path}#{get_runner_query_name}.json" do - post_graphql(query, current_user: admin, variables: { - id: instance_runner.to_global_id.to_s - }) + describe GraphQL::Query, type: :request do + get_runner_query_name = 'get_runner.query.graphql' - expect_graphql_errors_to_be_empty + let_it_be(:query) do + get_graphql_query_as_string("#{query_path}#{get_runner_query_name}") + end + + it "#{fixtures_path}#{get_runner_query_name}.json" do + post_graphql(query, current_user: admin, variables: { + id: instance_runner.to_global_id.to_s + }) + + expect_graphql_errors_to_be_empty + end end end - describe GraphQL::Query, type: :request do - get_group_runners_query_name = 'get_group_runners.query.graphql' - + describe do let_it_be(:group_owner) { create(:user) } before do group.add_owner(group_owner) end - let_it_be(:query) do - get_graphql_query_as_string("#{query_path}#{get_group_runners_query_name}") - end + describe GraphQL::Query, type: :request do + get_group_runners_query_name = 'get_group_runners.query.graphql' + + let_it_be(:query) do + get_graphql_query_as_string("#{query_path}#{get_group_runners_query_name}") + end - it "#{fixtures_path}#{get_group_runners_query_name}.json" do - post_graphql(query, current_user: group_owner, variables: { - groupFullPath: group.full_path - }) + it "#{fixtures_path}#{get_group_runners_query_name}.json" do + post_graphql(query, current_user: group_owner, variables: { + groupFullPath: group.full_path + }) - expect_graphql_errors_to_be_empty + expect_graphql_errors_to_be_empty + end + + it "#{fixtures_path}#{get_group_runners_query_name}.paginated.json" do + post_graphql(query, current_user: group_owner, variables: { + groupFullPath: group.full_path, + first: 1 + }) + + expect_graphql_errors_to_be_empty + end end - it "#{fixtures_path}#{get_group_runners_query_name}.paginated.json" do - post_graphql(query, current_user: group_owner, variables: { - groupFullPath: group.full_path, - first: 1 - }) + describe GraphQL::Query, type: :request do + get_group_runners_count_query_name = 'get_group_runners_count.query.graphql' + + let_it_be(:query) do + get_graphql_query_as_string("#{query_path}#{get_group_runners_count_query_name}") + end + + it "#{fixtures_path}#{get_group_runners_count_query_name}.json" do + post_graphql(query, current_user: group_owner, variables: { + groupFullPath: group.full_path + }) - expect_graphql_errors_to_be_empty + expect_graphql_errors_to_be_empty + end end end end diff --git a/spec/frontend/fixtures/static/project_select_combo_button.html b/spec/frontend/fixtures/static/project_select_combo_button.html index 444e0bc84a2..3776610ed4c 100644 --- a/spec/frontend/fixtures/static/project_select_combo_button.html +++ b/spec/frontend/fixtures/static/project_select_combo_button.html @@ -1,6 +1,6 @@ <div class="project-item-select-holder"> <input class="project-item-select" data-group-id="12345" data-relative-path="issues/new" /> - <a class="new-project-item-link" data-label="New issue" data-type="issues" href=""> + <a class="js-new-project-item-link" data-label="issue" data-type="issues" href=""> <span class="gl-spinner"></span> </a> <a class="new-project-item-select-button"> diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js index fc736f2d155..d5451ec2064 100644 --- a/spec/frontend/flash_spec.js +++ b/spec/frontend/flash_spec.js @@ -1,9 +1,12 @@ import * as Sentry from '@sentry/browser'; +import { setHTMLFixture } from 'helpers/fixtures'; import createFlash, { hideFlash, addDismissFlashClickListener, FLASH_TYPES, FLASH_CLOSED_EVENT, + createAlert, + VARIANT_WARNING, } from '~/flash'; jest.mock('@sentry/browser'); @@ -68,6 +71,236 @@ describe('Flash', () => { }); }); + describe('createAlert', () => { + const mockMessage = 'a message'; + let alert; + + describe('no flash-container', () => { + it('does not add to the DOM', () => { + alert = createAlert({ message: mockMessage }); + + expect(alert).toBeNull(); + expect(document.querySelector('.gl-alert')).toBeNull(); + }); + }); + + describe('with flash-container', () => { + beforeEach(() => { + setHTMLFixture('<div class="flash-container"></div>'); + }); + + afterEach(() => { + if (alert) { + alert.$destroy(); + } + document.querySelector('.flash-container')?.remove(); + }); + + it('adds alert element into the document by default', () => { + alert = createAlert({ message: mockMessage }); + + expect(document.querySelector('.flash-container').textContent.trim()).toBe(mockMessage); + expect(document.querySelector('.flash-container .gl-alert')).not.toBeNull(); + }); + + it('adds flash of a warning type', () => { + alert = createAlert({ message: mockMessage, variant: VARIANT_WARNING }); + + expect( + document.querySelector('.flash-container .gl-alert.gl-alert-warning'), + ).not.toBeNull(); + }); + + it('escapes text', () => { + alert = createAlert({ message: '<script>alert("a");</script>' }); + + const html = document.querySelector('.flash-container').innerHTML; + + expect(html).toContain('<script>alert("a");</script>'); + expect(html).not.toContain('<script>alert("a");</script>'); + }); + + it('adds alert into specified container', () => { + setHTMLFixture(` + <div class="my-alert-container"></div> + <div class="my-other-container"></div> + `); + + alert = createAlert({ message: mockMessage, containerSelector: '.my-alert-container' }); + + expect(document.querySelector('.my-alert-container .gl-alert')).not.toBeNull(); + expect(document.querySelector('.my-alert-container').innerText.trim()).toBe(mockMessage); + + expect(document.querySelector('.my-other-container .gl-alert')).toBeNull(); + expect(document.querySelector('.my-other-container').innerText.trim()).toBe(''); + }); + + it('adds alert into specified parent', () => { + setHTMLFixture(` + <div id="my-parent"> + <div class="flash-container"></div> + </div> + <div id="my-other-parent"> + <div class="flash-container"></div> + </div> + `); + + alert = createAlert({ message: mockMessage, parent: document.getElementById('my-parent') }); + + expect(document.querySelector('#my-parent .flash-container .gl-alert')).not.toBeNull(); + expect(document.querySelector('#my-parent .flash-container').innerText.trim()).toBe( + mockMessage, + ); + + expect(document.querySelector('#my-other-parent .flash-container .gl-alert')).toBeNull(); + expect(document.querySelector('#my-other-parent .flash-container').innerText.trim()).toBe( + '', + ); + }); + + it('removes element after clicking', () => { + alert = createAlert({ message: mockMessage }); + + expect(document.querySelector('.flash-container .gl-alert')).not.toBeNull(); + + document.querySelector('.gl-dismiss-btn').click(); + + expect(document.querySelector('.flash-container .gl-alert')).toBeNull(); + }); + + it('does not capture error using Sentry', () => { + alert = createAlert({ + message: mockMessage, + captureError: false, + error: new Error('Error!'), + }); + + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it('captures error using Sentry', () => { + alert = createAlert({ + message: mockMessage, + captureError: true, + error: new Error('Error!'), + }); + + expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error)); + expect(Sentry.captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Error!', + }), + ); + }); + + describe('with buttons', () => { + const findAlertAction = () => document.querySelector('.flash-container .gl-alert-action'); + + it('adds primary button', () => { + alert = createAlert({ + message: mockMessage, + primaryButton: { + text: 'Ok', + }, + }); + + expect(findAlertAction().textContent.trim()).toBe('Ok'); + }); + + it('creates link with href', () => { + alert = createAlert({ + message: mockMessage, + primaryButton: { + link: '/url', + text: 'Ok', + }, + }); + + const action = findAlertAction(); + + expect(action.textContent.trim()).toBe('Ok'); + expect(action.nodeName).toBe('A'); + expect(action.getAttribute('href')).toBe('/url'); + }); + + it('create button as href when no href is present', () => { + alert = createAlert({ + message: mockMessage, + primaryButton: { + text: 'Ok', + }, + }); + + const action = findAlertAction(); + + expect(action.nodeName).toBe('BUTTON'); + expect(action.getAttribute('href')).toBe(null); + }); + + it('escapes the title text', () => { + alert = createAlert({ + message: mockMessage, + primaryButton: { + text: '<script>alert("a")</script>', + }, + }); + + const html = findAlertAction().innerHTML; + + expect(html).toContain('<script>alert("a")</script>'); + expect(html).not.toContain('<script>alert("a")</script>'); + }); + + it('calls actionConfig clickHandler on click', () => { + const clickHandler = jest.fn(); + + alert = createAlert({ + message: mockMessage, + primaryButton: { + text: 'Ok', + clickHandler, + }, + }); + + expect(clickHandler).toHaveBeenCalledTimes(0); + + findAlertAction().click(); + + expect(clickHandler).toHaveBeenCalledTimes(1); + expect(clickHandler).toHaveBeenCalledWith(expect.any(MouseEvent)); + }); + }); + + describe('Alert API', () => { + describe('dismiss', () => { + it('dismiss programmatically with .dismiss()', () => { + expect(document.querySelector('.gl-alert')).toBeNull(); + + alert = createAlert({ message: mockMessage }); + + expect(document.querySelector('.gl-alert')).not.toBeNull(); + + alert.dismiss(); + + expect(document.querySelector('.gl-alert')).toBeNull(); + }); + + it('calls onDismiss when dismissed', () => { + const dismissHandler = jest.fn(); + + alert = createAlert({ message: mockMessage, onDismiss: dismissHandler }); + + expect(dismissHandler).toHaveBeenCalledTimes(0); + + alert.dismiss(); + + expect(dismissHandler).toHaveBeenCalledTimes(1); + }); + }); + }); + }); + }); + describe('createFlash', () => { const message = 'test'; const fadeTransition = false; @@ -91,7 +324,7 @@ describe('Flash', () => { describe('with flash-container', () => { beforeEach(() => { - setFixtures( + setHTMLFixture( '<div class="content-wrapper js-content-wrapper"><div class="flash-container"></div></div>', ); }); @@ -115,11 +348,12 @@ describe('Flash', () => { }); it('escapes text', () => { - createFlash({ ...defaultParams, message: '<script>alert("a");</script>' }); + createFlash({ ...defaultParams, message: '<script>alert("a")</script>' }); - expect(document.querySelector('.flash-text').textContent.trim()).toBe( - '<script>alert("a");</script>', - ); + const html = document.querySelector('.flash-text').innerHTML; + + expect(html).toContain('<script>alert("a")</script>'); + expect(html).not.toContain('<script>alert("a")</script>'); }); it('adds flash into specified parent', () => { @@ -193,8 +427,10 @@ describe('Flash', () => { }, }); - expect(findFlashAction().href).toBe(`${window.location}testing`); - expect(findFlashAction().textContent.trim()).toBe('test'); + const action = findFlashAction(); + + expect(action.href).toBe(`${window.location}testing`); + expect(action.textContent.trim()).toBe('test'); }); it('uses hash as href when no href is present', () => { @@ -227,7 +463,10 @@ describe('Flash', () => { }, }); - expect(findFlashAction().textContent.trim()).toBe('<script>alert("a")</script>'); + const html = findFlashAction().innerHTML; + + expect(html).toContain('<script>alert("a")</script>'); + expect(html).not.toContain('<script>alert("a")</script>'); }); it('calls actionConfig clickHandler on click', () => { diff --git a/spec/frontend/google_cloud/components/app_spec.js b/spec/frontend/google_cloud/components/app_spec.js index 570ac1e6ed1..92bc7596f7d 100644 --- a/spec/frontend/google_cloud/components/app_spec.js +++ b/spec/frontend/google_cloud/components/app_spec.js @@ -24,6 +24,8 @@ const HOME_PROPS = { serviceAccounts: [{}, {}], createServiceAccountUrl: '#url-create-service-account', emptyIllustrationUrl: '#url-empty-illustration', + deploymentsCloudRunUrl: '#url-deployments-cloud-run', + deploymentsCloudStorageUrl: '#deploymentsCloudStorageUrl', }; describe('google_cloud App component', () => { diff --git a/spec/frontend/google_cloud/components/deployments_service_table_spec.js b/spec/frontend/google_cloud/components/deployments_service_table_spec.js new file mode 100644 index 00000000000..76c3bfd00a8 --- /dev/null +++ b/spec/frontend/google_cloud/components/deployments_service_table_spec.js @@ -0,0 +1,40 @@ +import { mount } from '@vue/test-utils'; +import { GlButton, GlTable } from '@gitlab/ui'; +import DeploymentsServiceTable from '~/google_cloud/components/deployments_service_table.vue'; + +describe('google_cloud DeploymentsServiceTable component', () => { + let wrapper; + + const findTable = () => wrapper.findComponent(GlTable); + const findButtons = () => findTable().findAllComponents(GlButton); + const findCloudRunButton = () => findButtons().at(0); + const findCloudStorageButton = () => findButtons().at(1); + + beforeEach(() => { + const propsData = { + cloudRunUrl: '#url-deployments-cloud-run', + cloudStorageUrl: '#url-deployments-cloud-storage', + }; + wrapper = mount(DeploymentsServiceTable, { propsData }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should contain a table', () => { + expect(findTable().exists()).toBe(true); + }); + + it('should contain configure cloud run button', () => { + const cloudRunButton = findCloudRunButton(); + expect(cloudRunButton.exists()).toBe(true); + expect(cloudRunButton.props().disabled).toBe(true); + }); + + it('should contain configure cloud storage button', () => { + const cloudStorageButton = findCloudStorageButton(); + expect(cloudStorageButton.exists()).toBe(true); + expect(cloudStorageButton.props().disabled).toBe(true); + }); +}); diff --git a/spec/frontend/google_cloud/components/home_spec.js b/spec/frontend/google_cloud/components/home_spec.js index 9b4c3a79f11..3a009fc88ce 100644 --- a/spec/frontend/google_cloud/components/home_spec.js +++ b/spec/frontend/google_cloud/components/home_spec.js @@ -20,6 +20,8 @@ describe('google_cloud Home component', () => { serviceAccounts: [{}, {}], createServiceAccountUrl: '#url-create-service-account', emptyIllustrationUrl: '#url-empty-illustration', + deploymentsCloudRunUrl: '#url-deployments-cloud-run', + deploymentsCloudStorageUrl: '#deploymentsCloudStorageUrl', }; beforeEach(() => { @@ -42,7 +44,7 @@ describe('google_cloud Home component', () => { it('should contain three tab items', () => { expect(findTabItemsModel()).toEqual([ { title: 'Configuration', disabled: undefined }, - { title: 'Deployments', disabled: '' }, + { title: 'Deployments', disabled: undefined }, { title: 'Services', disabled: '' }, ]); }); diff --git a/spec/frontend/google_tag_manager/index_spec.js b/spec/frontend/google_tag_manager/index_spec.js new file mode 100644 index 00000000000..ff38de28da6 --- /dev/null +++ b/spec/frontend/google_tag_manager/index_spec.js @@ -0,0 +1,259 @@ +import { merge } from 'lodash'; +import { + trackFreeTrialAccountSubmissions, + trackNewRegistrations, + trackSaasTrialSubmit, + trackSaasTrialSkip, + trackSaasTrialGroup, + trackSaasTrialProject, + trackSaasTrialProjectImport, + trackSaasTrialGetStarted, +} from '~/google_tag_manager'; +import { setHTMLFixture } from 'helpers/fixtures'; +import { logError } from '~/lib/logger'; + +jest.mock('~/lib/logger'); + +describe('~/google_tag_manager/index', () => { + let spy; + + beforeEach(() => { + spy = jest.fn(); + + window.dataLayer = { + push: spy, + }; + window.gon.features = { + gitlabGtmDatalayer: true, + }; + }); + + const createHTML = ({ links = [], forms = [] } = {}) => { + // .foo elements are used to test elements which shouldn't do anything + const allLinks = links.concat({ cls: 'foo' }); + const allForms = forms.concat({ cls: 'foo' }); + + const el = document.createElement('div'); + + allLinks.forEach(({ cls = '', id = '', href = '#', text = 'Hello', attributes = {} }) => { + const a = document.createElement('a'); + a.id = id; + a.href = href || '#'; + a.className = cls; + a.textContent = text; + + Object.entries(attributes).forEach(([key, value]) => { + a.setAttribute(key, value); + }); + + el.append(a); + }); + + allForms.forEach(({ cls = '', id = '' }) => { + const form = document.createElement('form'); + form.id = id; + form.className = cls; + + el.append(form); + }); + + return el.innerHTML; + }; + + const triggerEvent = (selector, eventType) => { + const el = document.querySelector(selector); + + el.dispatchEvent(new Event(eventType)); + }; + + const getSelector = ({ id, cls }) => (id ? `#${id}` : `.${cls}`); + + const createTestCase = (subject, { forms = [], links = [] }) => { + const expectedFormEvents = forms.map(({ expectation, ...form }) => ({ + selector: getSelector(form), + trigger: 'submit', + expectation, + })); + + const expectedLinkEvents = links.map(({ expectation, ...link }) => ({ + selector: getSelector(link), + trigger: 'click', + expectation, + })); + + return [ + subject, + { + forms, + links, + expectedEvents: [...expectedFormEvents, ...expectedLinkEvents], + }, + ]; + }; + + const createOmniAuthTestCase = (subject, accountType) => + createTestCase(subject, { + forms: [ + { + id: 'new_new_user', + expectation: { + event: 'accountSubmit', + accountMethod: 'form', + accountType, + }, + }, + ], + links: [ + { + // id is needed so that the test selects the right element to trigger + id: 'test-0', + cls: 'js-oauth-login', + attributes: { + 'data-provider': 'myspace', + }, + expectation: { + event: 'accountSubmit', + accountMethod: 'myspace', + accountType, + }, + }, + { + id: 'test-1', + cls: 'js-oauth-login', + attributes: { + 'data-provider': 'gitlab', + }, + expectation: { + event: 'accountSubmit', + accountMethod: 'gitlab', + accountType, + }, + }, + ], + }); + + describe.each([ + createOmniAuthTestCase(trackFreeTrialAccountSubmissions, 'freeThirtyDayTrial'), + createOmniAuthTestCase(trackNewRegistrations, 'standardSignUp'), + createTestCase(trackSaasTrialSkip, { + links: [{ cls: 'js-skip-trial', expectation: { event: 'saasTrialSkip' } }], + }), + createTestCase(trackSaasTrialGroup, { + forms: [{ cls: 'js-saas-trial-group', expectation: { event: 'saasTrialGroup' } }], + }), + createTestCase(trackSaasTrialProject, { + forms: [{ id: 'new_project', expectation: { event: 'saasTrialProject' } }], + }), + createTestCase(trackSaasTrialProjectImport, { + links: [ + { + id: 'js-test-btn-0', + cls: 'js-import-project-btn', + attributes: { 'data-platform': 'bitbucket' }, + expectation: { event: 'saasTrialProjectImport', saasProjectImport: 'bitbucket' }, + }, + { + // id is neeeded so we trigger the right element in the test + id: 'js-test-btn-1', + cls: 'js-import-project-btn', + attributes: { 'data-platform': 'github' }, + expectation: { event: 'saasTrialProjectImport', saasProjectImport: 'github' }, + }, + ], + }), + createTestCase(trackSaasTrialGetStarted, { + links: [ + { + cls: 'js-get-started-btn', + expectation: { event: 'saasTrialGetStarted' }, + }, + ], + }), + ])('%p', (subject, { links = [], forms = [], expectedEvents }) => { + beforeEach(() => { + setHTMLFixture(createHTML({ links, forms })); + + subject(); + }); + + it.each(expectedEvents)('when %p', ({ selector, trigger, expectation }) => { + expect(spy).not.toHaveBeenCalled(); + + triggerEvent(selector, trigger); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(expectation); + expect(logError).not.toHaveBeenCalled(); + }); + + it('when random link is clicked, does nothing', () => { + triggerEvent('a.foo', 'click'); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('when random form is submitted, does nothing', () => { + triggerEvent('form.foo', 'submit'); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('No listener events', () => { + it('when trackSaasTrialSubmit is invoked', () => { + expect(spy).not.toHaveBeenCalled(); + + trackSaasTrialSubmit(); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith({ event: 'saasTrialSubmit' }); + expect(logError).not.toHaveBeenCalled(); + }); + }); + + describe.each([ + { dataLayer: null }, + { gon: { features: null } }, + { gon: { features: { gitlabGtmDatalayer: false } } }, + ])('when window %o', (windowAttrs) => { + beforeEach(() => { + merge(window, windowAttrs); + }); + + it('no ops', () => { + setHTMLFixture(createHTML({ forms: [{ id: 'new_project' }] })); + + trackSaasTrialProject(); + + triggerEvent('#new_project', 'submit'); + + expect(spy).not.toHaveBeenCalled(); + expect(logError).not.toHaveBeenCalled(); + }); + }); + + describe('when window.dataLayer throws error', () => { + const pushError = new Error('test'); + + beforeEach(() => { + window.dataLayer = { + push() { + throw pushError; + }, + }; + }); + + it('logs error', () => { + setHTMLFixture(createHTML({ forms: [{ id: 'new_project' }] })); + + trackSaasTrialProject(); + + triggerEvent('#new_project', 'submit'); + + expect(logError).toHaveBeenCalledWith( + 'Unexpected error while pushing to dataLayer', + pushError, + ); + }); + }); +}); diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js index 60d47895a95..8ea7e54aef4 100644 --- a/spec/frontend/groups/components/group_item_spec.js +++ b/spec/frontend/groups/components/group_item_spec.js @@ -100,6 +100,7 @@ describe('GroupItemComponent', () => { wrapper.destroy(); group.type = 'project'; + group.lastActivityAt = '2017-04-09T18:40:39.101Z'; wrapper = createComponent({ group }); expect(wrapper.vm.isGroup).toBe(false); diff --git a/spec/frontend/groups/components/item_stats_spec.js b/spec/frontend/groups/components/item_stats_spec.js index 49f3f5da43c..fdc267bc14a 100644 --- a/spec/frontend/groups/components/item_stats_spec.js +++ b/spec/frontend/groups/components/item_stats_spec.js @@ -38,6 +38,7 @@ describe('ItemStats', () => { ...mockParentGroupItem, type: ITEM_TYPE.PROJECT, starCount: 4, + lastActivityAt: '2017-04-09T18:40:39.101Z', }; createComponent({ item }); diff --git a/spec/frontend/landing_spec.js b/spec/frontend/groups/landing_spec.js index 448d8ee2e81..f90f541eb96 100644 --- a/spec/frontend/landing_spec.js +++ b/spec/frontend/groups/landing_spec.js @@ -1,5 +1,5 @@ import Cookies from 'js-cookie'; -import Landing from '~/landing'; +import Landing from '~/groups/landing'; describe('Landing', () => { const test = {}; diff --git a/spec/frontend/transfer_edit_spec.js b/spec/frontend/groups/transfer_edit_spec.js index 4091d753fe5..bc070920d02 100644 --- a/spec/frontend/transfer_edit_spec.js +++ b/spec/frontend/groups/transfer_edit_spec.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import { loadHTMLFixture } from 'helpers/fixtures'; -import setupTransferEdit from '~/transfer_edit'; +import setupTransferEdit from '~/groups/transfer_edit'; describe('setupTransferEdit', () => { const formSelector = '.js-group-transfer-form'; diff --git a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap index faa70982fac..d1cf9f2e248 100644 --- a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap +++ b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap @@ -25,11 +25,12 @@ exports[`IDE pipeline stage renders stage details & icon 1`] = ` <div class="gl-mr-3 gl-ml-2" > - <span - class="badge badge-pill" + <gl-badge-stub + size="md" + variant="muted" > - 4 - </span> + 4 + </gl-badge-stub> </div> <gl-icon-stub diff --git a/spec/frontend/ide/components/preview/clientside_spec.js b/spec/frontend/ide/components/preview/clientside_spec.js index 1768f01f3b8..b168eec0f16 100644 --- a/spec/frontend/ide/components/preview/clientside_spec.js +++ b/spec/frontend/ide/components/preview/clientside_spec.js @@ -73,6 +73,8 @@ describe('IDE clientside preview', () => { const createInitializedComponent = () => { createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ sandpackReady: true, manager: { @@ -202,6 +204,8 @@ describe('IDE clientside preview', () => { it('returns false if loading and mainEntry exists', () => { createComponent({ getters: { packageJson: dummyPackageJson } }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ loading: true }); expect(wrapper.vm.showPreview).toBe(false); @@ -209,6 +213,8 @@ describe('IDE clientside preview', () => { it('returns true if not loading and mainEntry exists', () => { createComponent({ getters: { packageJson: dummyPackageJson } }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ loading: false }); expect(wrapper.vm.showPreview).toBe(true); @@ -218,12 +224,16 @@ describe('IDE clientside preview', () => { describe('showEmptyState', () => { it('returns true if no mainEntry exists', () => { createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ loading: false }); expect(wrapper.vm.showEmptyState).toBe(true); }); it('returns false if loading', () => { createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ loading: true }); expect(wrapper.vm.showEmptyState).toBe(false); @@ -231,6 +241,8 @@ describe('IDE clientside preview', () => { it('returns false if not loading and mainEntry exists', () => { createComponent({ getters: { packageJson: dummyPackageJson } }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ loading: false }); expect(wrapper.vm.showEmptyState).toBe(false); @@ -307,6 +319,8 @@ describe('IDE clientside preview', () => { describe('update', () => { it('initializes manager if manager is empty', () => { createComponent({ getters: { packageJson: dummyPackageJson } }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ sandpackReady: true }); wrapper.vm.update(); @@ -340,6 +354,8 @@ describe('IDE clientside preview', () => { describe('template', () => { it('renders ide-preview element when showPreview is true', () => { createComponent({ getters: { packageJson: dummyPackageJson } }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ loading: false }); return wrapper.vm.$nextTick(() => { @@ -349,6 +365,8 @@ describe('IDE clientside preview', () => { it('renders empty state', () => { createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ loading: false }); return wrapper.vm.$nextTick(() => { @@ -360,6 +378,8 @@ describe('IDE clientside preview', () => { it('renders loading icon', () => { createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ loading: true }); return wrapper.vm.$nextTick(() => { diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index c957c64aa10..15af2d03704 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -5,7 +5,6 @@ import Vue from 'vue'; import Vuex from 'vuex'; import '~/behaviors/markdown/render_gfm'; import waitForPromises from 'helpers/wait_for_promises'; -import waitUsingRealTimer from 'helpers/wait_using_real_timer'; import { exampleConfigs, exampleFiles } from 'jest/ide/lib/editorconfig/mock_data'; import { EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN } from '~/editor/constants'; import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; @@ -540,7 +539,6 @@ describe('RepoEditor', () => { }, }); await vm.$nextTick(); - await vm.$nextTick(); expect(vm.initEditor).toHaveBeenCalled(); }); @@ -567,8 +565,8 @@ describe('RepoEditor', () => { // switching from edit to diff mode usually triggers editor initialization vm.$store.state.viewer = viewerTypes.diff; - // we delay returning the file to make sure editor doesn't initialize before we fetch file content - await waitUsingRealTimer(30); + jest.runOnlyPendingTimers(); + return 'rawFileData123\n'; }); @@ -598,8 +596,9 @@ describe('RepoEditor', () => { return aContent; }) .mockImplementationOnce(async () => { - // we delay returning fileB content to make sure the editor doesn't initialize prematurely - await waitUsingRealTimer(30); + // we delay returning fileB content + // to make sure the editor doesn't initialize prematurely + jest.advanceTimersByTime(30); return bContent; }); diff --git a/spec/frontend/ide/components/terminal/terminal_spec.js b/spec/frontend/ide/components/terminal/terminal_spec.js index c4b186c004a..afc49e22c83 100644 --- a/spec/frontend/ide/components/terminal/terminal_spec.js +++ b/spec/frontend/ide/components/terminal/terminal_spec.js @@ -128,6 +128,8 @@ describe('IDE Terminal', () => { canScrollDown: false, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ canScrollUp: true, canScrollDown: true }); return nextTick().then(() => { diff --git a/spec/frontend/ide/stores/modules/pipelines/actions_spec.js b/spec/frontend/ide/stores/modules/pipelines/actions_spec.js index 9aa31136c89..3ede37e2eed 100644 --- a/spec/frontend/ide/stores/modules/pipelines/actions_spec.js +++ b/spec/frontend/ide/stores/modules/pipelines/actions_spec.js @@ -188,6 +188,24 @@ describe('IDE pipelines actions', () => { .catch(done.fail); }); }); + + it('sets latest pipeline to `null` and stops polling on empty project', (done) => { + mockedState = { + ...mockedState, + rootGetters: { + lastCommit: null, + }, + }; + + testAction( + fetchLatestPipeline, + {}, + mockedState, + [{ type: types.RECEIVE_LASTEST_PIPELINE_SUCCESS, payload: null }], + [{ type: 'stopPipelinePolling' }], + done, + ); + }); }); describe('requestJobs', () => { diff --git a/spec/frontend/integrations/edit/components/dynamic_field_spec.js b/spec/frontend/integrations/edit/components/dynamic_field_spec.js index bf044e388ea..b0fb94d2b29 100644 --- a/spec/frontend/integrations/edit/components/dynamic_field_spec.js +++ b/spec/frontend/integrations/edit/components/dynamic_field_spec.js @@ -61,7 +61,7 @@ describe('DynamicField', () => { }); it(`renders GlFormCheckbox with correct text content when checkboxLabel is ${checkboxLabel}`, () => { - expect(findGlFormCheckbox().text()).toBe(checkboxLabel ?? defaultProps.title); + expect(findGlFormCheckbox().text()).toContain(checkboxLabel ?? defaultProps.title); }); it('does not render other types of input', () => { @@ -182,6 +182,17 @@ describe('DynamicField', () => { expect(findGlFormGroup().find('small').text()).toBe(defaultProps.help); }); + describe('when type is checkbox', () => { + it('renders description with help text', () => { + createComponent({ + type: 'checkbox', + }); + + expect(findGlFormGroup().find('small').exists()).toBe(false); + expect(findGlFormCheckbox().text()).toContain(defaultProps.help); + }); + }); + it('renders description with help text as HTML', () => { const helpHTML = 'The <strong>URL</strong> of the project'; diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index 4c1394f3a87..8cf8a403e5d 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -1,9 +1,10 @@ +import { GlForm } from '@gitlab/ui'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import * as Sentry from '@sentry/browser'; import { setHTMLFixture } from 'helpers/fixtures'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { mockIntegrationProps } from 'jest/integrations/edit/mock_data'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue'; import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue'; import DynamicField from '~/integrations/edit/components/dynamic_field.vue'; @@ -13,7 +14,6 @@ import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_field import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue'; import ResetConfirmationModal from '~/integrations/edit/components/reset_confirmation_modal.vue'; import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; -import waitForPromises from 'helpers/wait_for_promises'; import { integrationLevels, I18N_SUCCESSFUL_CONNECTION_MESSAGE, @@ -23,9 +23,12 @@ import { import { createStore } from '~/integrations/edit/store'; import eventHub from '~/integrations/edit/event_hub'; import httpStatus from '~/lib/utils/http_status'; +import { refreshCurrentPage } from '~/lib/utils/url_utility'; +import { mockIntegrationProps } from '../mock_data'; jest.mock('~/integrations/edit/event_hub'); jest.mock('@sentry/browser'); +jest.mock('~/lib/utils/url_utility'); describe('IntegrationForm', () => { const mockToastShow = jest.fn(); @@ -34,12 +37,18 @@ describe('IntegrationForm', () => { let dispatch; let mockAxios; let mockForm; + let vueIntegrationFormFeatureFlag; + + const createForm = () => { + mockForm = document.createElement('form'); + jest.spyOn(document, 'querySelector').mockReturnValue(mockForm); + }; const createComponent = ({ customStateProps = {}, - featureFlags = {}, initialState = {}, props = {}, + mountFn = shallowMountExtended, } = {}) => { const store = createStore({ customState: { ...mockIntegrationProps, ...customStateProps }, @@ -47,11 +56,12 @@ describe('IntegrationForm', () => { }); dispatch = jest.spyOn(store, 'dispatch').mockImplementation(); - wrapper = shallowMountExtended(IntegrationForm, { - propsData: { ...props, formSelector: '.test' }, - provide: { - glFeatures: featureFlags, - }, + if (!vueIntegrationFormFeatureFlag) { + createForm(); + } + + wrapper = mountFn(IntegrationForm, { + propsData: { ...props }, store, stubs: { OverrideDropdown, @@ -65,26 +75,33 @@ describe('IntegrationForm', () => { show: mockToastShow, }, }, + provide: { + glFeatures: { + vueIntegrationForm: vueIntegrationFormFeatureFlag, + }, + }, }); }; - const createForm = ({ isValid = true } = {}) => { - mockForm = document.createElement('form'); - jest.spyOn(document, 'querySelector').mockReturnValue(mockForm); - jest.spyOn(mockForm, 'checkValidity').mockReturnValue(isValid); - jest.spyOn(mockForm, 'submit'); - }; - const findOverrideDropdown = () => wrapper.findComponent(OverrideDropdown); const findActiveCheckbox = () => wrapper.findComponent(ActiveCheckbox); const findConfirmationModal = () => wrapper.findComponent(ConfirmationModal); const findResetConfirmationModal = () => wrapper.findComponent(ResetConfirmationModal); const findResetButton = () => wrapper.findByTestId('reset-button'); - const findSaveButton = () => wrapper.findByTestId('save-button'); + const findProjectSaveButton = () => wrapper.findByTestId('save-button'); + const findInstanceOrGroupSaveButton = () => wrapper.findByTestId('save-button-instance-group'); const findTestButton = () => wrapper.findByTestId('test-button'); const findJiraTriggerFields = () => wrapper.findComponent(JiraTriggerFields); const findJiraIssuesFields = () => wrapper.findComponent(JiraIssuesFields); const findTriggerFields = () => wrapper.findComponent(TriggerFields); + const findGlForm = () => wrapper.findComponent(GlForm); + const findRedirectToField = () => wrapper.findByTestId('redirect-to-field'); + const findFormElement = () => (vueIntegrationFormFeatureFlag ? findGlForm().element : mockForm); + + const mockFormFunctions = ({ checkValidityReturn }) => { + jest.spyOn(findFormElement(), 'checkValidity').mockReturnValue(checkValidityReturn); + jest.spyOn(findFormElement(), 'submit'); + }; beforeEach(() => { mockAxios = new MockAdapter(axios); @@ -220,6 +237,7 @@ describe('IntegrationForm', () => { createComponent({ customStateProps: { type: 'jira', testPath: '/test' }, + mountFn: mountExtended, }); }); @@ -338,6 +356,19 @@ describe('IntegrationForm', () => { }); }); }); + + describe('when `vueIntegrationForm` feature flag is $vueIntegrationFormEnabled', () => { + it('renders hidden fields', () => { + vueIntegrationFormFeatureFlag = true; + createComponent({ + customStateProps: { + redirectTo: '/services', + }, + }); + + expect(findRedirectToField().attributes('value')).toBe('/services'); + }); + }); }); describe('ActiveCheckbox', () => { @@ -358,190 +389,292 @@ describe('IntegrationForm', () => { }); describe.each` - formActive | novalidate - ${true} | ${null} - ${false} | ${'true'} + formActive | vueIntegrationFormEnabled | novalidate + ${true} | ${true} | ${null} + ${false} | ${true} | ${'novalidate'} + ${true} | ${false} | ${null} + ${false} | ${false} | ${'true'} `( - 'when `toggle-integration-active` is emitted with $formActive', - ({ formActive, novalidate }) => { + 'when `vueIntegrationForm` feature flag is $vueIntegrationFormEnabled and `toggle-integration-active` is emitted with $formActive', + ({ formActive, vueIntegrationFormEnabled, novalidate }) => { beforeEach(async () => { - createForm(); + vueIntegrationFormFeatureFlag = vueIntegrationFormEnabled; + createComponent({ customStateProps: { showActive: true, initialActivated: false, }, + mountFn: mountExtended, }); + mockFormFunctions({ checkValidityReturn: false }); await findActiveCheckbox().vm.$emit('toggle-integration-active', formActive); }); it(`sets noValidate to ${novalidate}`, () => { - expect(mockForm.getAttribute('novalidate')).toBe(novalidate); + expect(findFormElement().getAttribute('novalidate')).toBe(novalidate); }); }, ); }); - describe('when `save` button is clicked', () => { - describe('buttons', () => { - beforeEach(async () => { - createForm(); - createComponent({ - customStateProps: { - showActive: true, - canTest: true, - initialActivated: true, - }, + describe.each` + vueIntegrationFormEnabled + ${true} + ${false} + `( + 'when `vueIntegrationForm` feature flag is $vueIntegrationFormEnabled', + ({ vueIntegrationFormEnabled }) => { + beforeEach(() => { + vueIntegrationFormFeatureFlag = vueIntegrationFormEnabled; + }); + + describe('when `save` button is clicked', () => { + describe('buttons', () => { + beforeEach(async () => { + createComponent({ + customStateProps: { + showActive: true, + canTest: true, + initialActivated: true, + }, + mountFn: mountExtended, + }); + + await findProjectSaveButton().vm.$emit('click', new Event('click')); + }); + + it('sets save button `loading` prop to `true`', () => { + expect(findProjectSaveButton().props('loading')).toBe(true); + }); + + it('sets test button `disabled` prop to `true`', () => { + expect(findTestButton().props('disabled')).toBe(true); + }); }); - await findSaveButton().vm.$emit('click', new Event('click')); - }); + describe.each` + checkValidityReturn | integrationActive + ${true} | ${false} + ${true} | ${true} + ${false} | ${false} + `( + 'when form is valid (checkValidity returns $checkValidityReturn and integrationActive is $integrationActive)', + ({ integrationActive, checkValidityReturn }) => { + beforeEach(async () => { + createComponent({ + customStateProps: { + showActive: true, + canTest: true, + initialActivated: integrationActive, + }, + mountFn: mountExtended, + }); + + mockFormFunctions({ checkValidityReturn }); + + await findProjectSaveButton().vm.$emit('click', new Event('click')); + }); + + it('submits form', () => { + expect(findFormElement().submit).toHaveBeenCalledTimes(1); + }); + }, + ); + + describe('when form is invalid (checkValidity returns false and integrationActive is true)', () => { + beforeEach(async () => { + createComponent({ + customStateProps: { + showActive: true, + canTest: true, + initialActivated: true, + }, + mountFn: mountExtended, + }); + mockFormFunctions({ checkValidityReturn: false }); + + await findProjectSaveButton().vm.$emit('click', new Event('click')); + }); - it('sets save button `loading` prop to `true`', () => { - expect(findSaveButton().props('loading')).toBe(true); - }); + it('does not submit form', () => { + expect(findFormElement().submit).not.toHaveBeenCalled(); + }); - it('sets test button `disabled` prop to `true`', () => { - expect(findTestButton().props('disabled')).toBe(true); - }); - }); + it('sets save button `loading` prop to `false`', () => { + expect(findProjectSaveButton().props('loading')).toBe(false); + }); - describe.each` - checkValidityReturn | integrationActive - ${true} | ${false} - ${true} | ${true} - ${false} | ${false} - `( - 'when form is valid (checkValidity returns $checkValidityReturn and integrationActive is $integrationActive)', - ({ integrationActive, checkValidityReturn }) => { - beforeEach(async () => { - createForm({ isValid: checkValidityReturn }); - createComponent({ - customStateProps: { - showActive: true, - canTest: true, - initialActivated: integrationActive, - }, + it('sets test button `disabled` prop to `false`', () => { + expect(findTestButton().props('disabled')).toBe(false); }); - await findSaveButton().vm.$emit('click', new Event('click')); + it('emits `VALIDATE_INTEGRATION_FORM_EVENT`', () => { + expect(eventHub.$emit).toHaveBeenCalledWith(VALIDATE_INTEGRATION_FORM_EVENT); + }); }); + }); - it('submit form', () => { - expect(mockForm.submit).toHaveBeenCalledTimes(1); - }); - }, - ); + describe('when `test` button is clicked', () => { + describe('when form is invalid', () => { + it('emits `VALIDATE_INTEGRATION_FORM_EVENT` event to the event hub', () => { + createComponent({ + customStateProps: { + showActive: true, + canTest: true, + }, + mountFn: mountExtended, + }); + mockFormFunctions({ checkValidityReturn: false }); - describe('when form is invalid (checkValidity returns false and integrationActive is true)', () => { - beforeEach(async () => { - createForm({ isValid: false }); - createComponent({ - customStateProps: { - showActive: true, - canTest: true, - initialActivated: true, - }, + findTestButton().vm.$emit('click', new Event('click')); + + expect(eventHub.$emit).toHaveBeenCalledWith(VALIDATE_INTEGRATION_FORM_EVENT); + }); }); - await findSaveButton().vm.$emit('click', new Event('click')); - }); + describe('when form is valid', () => { + const mockTestPath = '/test'; - it('does not submit form', () => { - expect(mockForm.submit).not.toHaveBeenCalled(); - }); + beforeEach(() => { + createComponent({ + customStateProps: { + showActive: true, + canTest: true, + testPath: mockTestPath, + }, + mountFn: mountExtended, + }); + mockFormFunctions({ checkValidityReturn: true }); + }); - it('sets save button `loading` prop to `false`', () => { - expect(findSaveButton().props('loading')).toBe(false); - }); + describe('buttons', () => { + beforeEach(async () => { + await findTestButton().vm.$emit('click', new Event('click')); + }); - it('sets test button `disabled` prop to `false`', () => { - expect(findTestButton().props('disabled')).toBe(false); - }); + it('sets test button `loading` prop to `true`', () => { + expect(findTestButton().props('loading')).toBe(true); + }); - it('emits `VALIDATE_INTEGRATION_FORM_EVENT`', () => { - expect(eventHub.$emit).toHaveBeenCalledWith(VALIDATE_INTEGRATION_FORM_EVENT); + it('sets save button `disabled` prop to `true`', () => { + expect(findProjectSaveButton().props('disabled')).toBe(true); + }); + }); + + describe.each` + scenario | replyStatus | errorMessage | expectToast | expectSentry + ${'when "test settings" request fails'} | ${httpStatus.INTERNAL_SERVER_ERROR} | ${undefined} | ${I18N_DEFAULT_ERROR_MESSAGE} | ${true} + ${'when "test settings" returns an error'} | ${httpStatus.OK} | ${'an error'} | ${'an error'} | ${false} + ${'when "test settings" succeeds'} | ${httpStatus.OK} | ${undefined} | ${I18N_SUCCESSFUL_CONNECTION_MESSAGE} | ${false} + `('$scenario', ({ replyStatus, errorMessage, expectToast, expectSentry }) => { + beforeEach(async () => { + mockAxios.onPut(mockTestPath).replyOnce(replyStatus, { + error: Boolean(errorMessage), + message: errorMessage, + }); + + await findTestButton().vm.$emit('click', new Event('click')); + await waitForPromises(); + }); + + it(`calls toast with '${expectToast}'`, () => { + expect(mockToastShow).toHaveBeenCalledWith(expectToast); + }); + + it('sets `loading` prop of test button to `false`', () => { + expect(findTestButton().props('loading')).toBe(false); + }); + + it('sets save button `disabled` prop to `false`', () => { + expect(findProjectSaveButton().props('disabled')).toBe(false); + }); + + it(`${expectSentry ? 'does' : 'does not'} capture exception in Sentry`, () => { + expect(Sentry.captureException).toHaveBeenCalledTimes(expectSentry ? 1 : 0); + }); + }); + }); }); - }); - }); + }, + ); + + describe('when `reset-confirmation-modal` emits `reset` event', () => { + const mockResetPath = '/reset'; - describe('when `test` button is clicked', () => { - describe('when form is invalid', () => { - it('emits `VALIDATE_INTEGRATION_FORM_EVENT` event to the event hub', () => { - createForm({ isValid: false }); + describe('buttons', () => { + beforeEach(async () => { createComponent({ customStateProps: { - showActive: true, + integrationLevel: integrationLevels.GROUP, canTest: true, + resetPath: mockResetPath, }, }); - findTestButton().vm.$emit('click', new Event('click')); + await findResetConfirmationModal().vm.$emit('reset'); + }); - expect(eventHub.$emit).toHaveBeenCalledWith(VALIDATE_INTEGRATION_FORM_EVENT); + it('sets reset button `loading` prop to `true`', () => { + expect(findResetButton().props('loading')).toBe(true); }); - }); - describe('when form is valid', () => { - const mockTestPath = '/test'; + it('sets other button `disabled` props to `true`', () => { + expect(findInstanceOrGroupSaveButton().props('disabled')).toBe(true); + expect(findTestButton().props('disabled')).toBe(true); + }); + }); - beforeEach(() => { - createForm({ isValid: true }); + describe('when "reset settings" request fails', () => { + beforeEach(async () => { + mockAxios.onPost(mockResetPath).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); createComponent({ customStateProps: { - showActive: true, + integrationLevel: integrationLevels.GROUP, canTest: true, - testPath: mockTestPath, + resetPath: mockResetPath, }, }); - }); - - describe('buttons', () => { - beforeEach(async () => { - await findTestButton().vm.$emit('click', new Event('click')); - }); - it('sets test button `loading` prop to `true`', () => { - expect(findTestButton().props('loading')).toBe(true); - }); + await findResetConfirmationModal().vm.$emit('reset'); + await waitForPromises(); + }); - it('sets save button `disabled` prop to `true`', () => { - expect(findSaveButton().props('disabled')).toBe(true); - }); + it('displays a toast', () => { + expect(mockToastShow).toHaveBeenCalledWith(I18N_DEFAULT_ERROR_MESSAGE); }); - describe.each` - scenario | replyStatus | errorMessage | expectToast | expectSentry - ${'when "test settings" request fails'} | ${httpStatus.INTERNAL_SERVER_ERROR} | ${undefined} | ${I18N_DEFAULT_ERROR_MESSAGE} | ${true} - ${'when "test settings" returns an error'} | ${httpStatus.OK} | ${'an error'} | ${'an error'} | ${false} - ${'when "test settings" succeeds'} | ${httpStatus.OK} | ${undefined} | ${I18N_SUCCESSFUL_CONNECTION_MESSAGE} | ${false} - `('$scenario', ({ replyStatus, errorMessage, expectToast, expectSentry }) => { - beforeEach(async () => { - mockAxios.onPut(mockTestPath).replyOnce(replyStatus, { - error: Boolean(errorMessage), - message: errorMessage, - }); + it('captures exception in Sentry', () => { + expect(Sentry.captureException).toHaveBeenCalledTimes(1); + }); - await findTestButton().vm.$emit('click', new Event('click')); - await waitForPromises(); - }); + it('sets reset button `loading` prop to `false`', () => { + expect(findResetButton().props('loading')).toBe(false); + }); - it(`calls toast with '${expectToast}'`, () => { - expect(mockToastShow).toHaveBeenCalledWith(expectToast); - }); + it('sets button `disabled` props to `false`', () => { + expect(findInstanceOrGroupSaveButton().props('disabled')).toBe(false); + expect(findTestButton().props('disabled')).toBe(false); + }); + }); - it('sets `loading` prop of test button to `false`', () => { - expect(findTestButton().props('loading')).toBe(false); + describe('when "reset settings" succeeds', () => { + beforeEach(async () => { + mockAxios.onPost(mockResetPath).replyOnce(httpStatus.OK); + createComponent({ + customStateProps: { + integrationLevel: integrationLevels.GROUP, + resetPath: mockResetPath, + }, }); - it('sets save button `disabled` prop to `false`', () => { - expect(findSaveButton().props('disabled')).toBe(false); - }); + await findResetConfirmationModal().vm.$emit('reset'); + await waitForPromises(); + }); - it(`${expectSentry ? 'does' : 'does not'} capture exception in Sentry`, () => { - expect(Sentry.captureException).toHaveBeenCalledTimes(expectSentry ? 1 : 0); - }); + it('calls `refreshCurrentPage`', () => { + expect(refreshCurrentPage).toHaveBeenCalledTimes(1); }); }); }); diff --git a/spec/frontend/integrations/edit/store/actions_spec.js b/spec/frontend/integrations/edit/store/actions_spec.js index b413de2b286..a5627d8b669 100644 --- a/spec/frontend/integrations/edit/store/actions_spec.js +++ b/spec/frontend/integrations/edit/store/actions_spec.js @@ -4,17 +4,12 @@ import testAction from 'helpers/vuex_action_helper'; import { I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE } from '~/integrations/constants'; import { setOverride, - setIsResetting, - requestResetIntegration, - receiveResetIntegrationSuccess, - receiveResetIntegrationError, requestJiraIssueTypes, receiveJiraIssueTypesSuccess, receiveJiraIssueTypesError, } from '~/integrations/edit/store/actions'; import * as types from '~/integrations/edit/store/mutation_types'; import createState from '~/integrations/edit/store/state'; -import { refreshCurrentPage } from '~/lib/utils/url_utility'; import { mockJiraIssueTypes } from '../mock_data'; jest.mock('~/lib/utils/url_utility'); @@ -38,38 +33,6 @@ describe('Integration form store actions', () => { }); }); - describe('setIsResetting', () => { - it('should commit isResetting mutation', () => { - return testAction(setIsResetting, true, state, [ - { type: types.SET_IS_RESETTING, payload: true }, - ]); - }); - }); - - describe('requestResetIntegration', () => { - it('should commit REQUEST_RESET_INTEGRATION mutation', () => { - return testAction(requestResetIntegration, null, state, [ - { type: types.REQUEST_RESET_INTEGRATION }, - ]); - }); - }); - - describe('receiveResetIntegrationSuccess', () => { - it('should call refreshCurrentPage()', () => { - return testAction(receiveResetIntegrationSuccess, null, state, [], [], () => { - expect(refreshCurrentPage).toHaveBeenCalled(); - }); - }); - }); - - describe('receiveResetIntegrationError', () => { - it('should commit RECEIVE_RESET_INTEGRATION_ERROR mutation', () => { - return testAction(receiveResetIntegrationError, null, state, [ - { type: types.RECEIVE_RESET_INTEGRATION_ERROR }, - ]); - }); - }); - describe('requestJiraIssueTypes', () => { describe.each` scenario | responseCode | response | action diff --git a/spec/frontend/integrations/edit/store/mutations_spec.js b/spec/frontend/integrations/edit/store/mutations_spec.js index 641547550d1..ecac9d88982 100644 --- a/spec/frontend/integrations/edit/store/mutations_spec.js +++ b/spec/frontend/integrations/edit/store/mutations_spec.js @@ -17,30 +17,6 @@ describe('Integration form store mutations', () => { }); }); - describe(`${types.SET_IS_RESETTING}`, () => { - it('sets isResetting', () => { - mutations[types.SET_IS_RESETTING](state, true); - - expect(state.isResetting).toBe(true); - }); - }); - - describe(`${types.REQUEST_RESET_INTEGRATION}`, () => { - it('sets isResetting', () => { - mutations[types.REQUEST_RESET_INTEGRATION](state); - - expect(state.isResetting).toBe(true); - }); - }); - - describe(`${types.RECEIVE_RESET_INTEGRATION_ERROR}`, () => { - it('sets isResetting', () => { - mutations[types.RECEIVE_RESET_INTEGRATION_ERROR](state); - - expect(state.isResetting).toBe(false); - }); - }); - describe(`${types.SET_JIRA_ISSUE_TYPES}`, () => { it('sets jiraIssueTypes', () => { const jiraIssueTypes = ['issue', 'epic']; diff --git a/spec/frontend/integrations/edit/store/state_spec.js b/spec/frontend/integrations/edit/store/state_spec.js index 5582be7fd3c..0b4ca8fb65c 100644 --- a/spec/frontend/integrations/edit/store/state_spec.js +++ b/spec/frontend/integrations/edit/store/state_spec.js @@ -5,8 +5,6 @@ describe('Integration form state factory', () => { expect(createState()).toEqual({ defaultState: null, customState: {}, - isSaving: false, - isResetting: false, override: false, isLoadingJiraIssueTypes: false, jiraIssueTypes: [], diff --git a/spec/frontend/integrations/overrides/components/integration_overrides_spec.js b/spec/frontend/integrations/overrides/components/integration_overrides_spec.js index 8abd83887f7..6aa3e661677 100644 --- a/spec/frontend/integrations/overrides/components/integration_overrides_spec.js +++ b/spec/frontend/integrations/overrides/components/integration_overrides_spec.js @@ -5,6 +5,8 @@ import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import { DEFAULT_PER_PAGE } from '~/api'; import IntegrationOverrides from '~/integrations/overrides/components/integration_overrides.vue'; +import IntegrationTabs from '~/integrations/overrides/components/integration_tabs.vue'; + import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; @@ -49,6 +51,7 @@ describe('IntegrationOverrides', () => { const findGlTable = () => wrapper.findComponent(GlTable); const findPagination = () => wrapper.findComponent(GlPagination); + const findIntegrationTabs = () => wrapper.findComponent(IntegrationTabs); const findRowsAsModel = () => findGlTable() .findAllComponents(GlLink) @@ -72,6 +75,12 @@ describe('IntegrationOverrides', () => { expect(table.exists()).toBe(true); expect(table.attributes('busy')).toBe('true'); }); + + it('renders IntegrationTabs with count as `null`', () => { + createComponent(); + + expect(findIntegrationTabs().props('projectOverridesCount')).toBe(null); + }); }); describe('when initial request is successful', () => { @@ -84,6 +93,13 @@ describe('IntegrationOverrides', () => { expect(table.attributes('busy')).toBeFalsy(); }); + it('renders IntegrationTabs with count', async () => { + createComponent(); + await waitForPromises(); + + expect(findIntegrationTabs().props('projectOverridesCount')).toBe(mockOverrides.length); + }); + describe('table template', () => { beforeEach(async () => { createComponent({ mountFn: mount }); diff --git a/spec/frontend/integrations/overrides/components/integration_tabs_spec.js b/spec/frontend/integrations/overrides/components/integration_tabs_spec.js new file mode 100644 index 00000000000..a728b4d391f --- /dev/null +++ b/spec/frontend/integrations/overrides/components/integration_tabs_spec.js @@ -0,0 +1,64 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import { GlBadge, GlTab } from '@gitlab/ui'; + +import IntegrationTabs from '~/integrations/overrides/components/integration_tabs.vue'; +import { settingsTabTitle, overridesTabTitle } from '~/integrations/constants'; + +describe('IntegrationTabs', () => { + let wrapper; + + const editPath = 'mock/edit'; + + const createComponent = ({ mountFn = shallowMount, props = {} } = {}) => { + wrapper = mountFn(IntegrationTabs, { + propsData: props, + provide: { + editPath, + }, + stubs: { + GlTab, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findGlBadge = () => wrapper.findComponent(GlBadge); + const findGlTab = () => wrapper.findComponent(GlTab); + const findSettingsLink = () => wrapper.find('a'); + + describe('template', () => { + it('renders "Settings" tab as a link', () => { + createComponent({ mountFn: mount }); + + expect(findSettingsLink().text()).toMatchInterpolatedText(settingsTabTitle); + expect(findSettingsLink().attributes('href')).toBe(editPath); + }); + + it('renders "Projects using custom settings" tab as active', () => { + const projectOverridesCount = '1'; + + createComponent({ + props: { projectOverridesCount }, + }); + + expect(findGlTab().exists()).toBe(true); + expect(findGlTab().text()).toMatchInterpolatedText( + `${overridesTabTitle} ${projectOverridesCount}`, + ); + expect(findGlBadge().text()).toBe(projectOverridesCount); + }); + + describe('when count is `null', () => { + it('renders "Projects using custom settings" tab without count', () => { + createComponent(); + + expect(findGlTab().exists()).toBe(true); + expect(findGlTab().text()).toMatchInterpolatedText(overridesTabTitle); + expect(findGlBadge().exists()).toBe(false); + }); + }); + }); +}); 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 e190ddf243e..3ab89b3dff2 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -474,6 +474,8 @@ describe('InviteMembersModal', () => { beforeEach(() => { createInviteMembersToGroupWrapper(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ newUsersToInvite: [user1] }); }); @@ -644,6 +646,8 @@ describe('InviteMembersModal', () => { beforeEach(() => { createInviteMembersToGroupWrapper(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ newUsersToInvite: [user3] }); }); @@ -712,6 +716,8 @@ describe('InviteMembersModal', () => { it('displays the invalid syntax error if one of the emails is invalid', async () => { createInviteMembersToGroupWrapper(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ newUsersToInvite: [user3, user4] }); mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.ERROR_EMAIL_INVALID); @@ -787,6 +793,8 @@ describe('InviteMembersModal', () => { beforeEach(() => { createInviteMembersToGroupWrapper(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ newUsersToInvite: [user1, user3] }); mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID); @@ -815,6 +823,8 @@ describe('InviteMembersModal', () => { beforeEach(() => { createComponent({ groupToBeSharedWith: sharedGroup }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ inviteeType: 'group' }); wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'groupShareWithGroup').mockResolvedValue({ data: groupPostData }); @@ -837,6 +847,8 @@ describe('InviteMembersModal', () => { beforeEach(() => { createInviteGroupToGroupWrapper(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ groupToBeSharedWith: sharedGroup }); wrapper.vm.$toast = { show: jest.fn() }; diff --git a/spec/frontend/invite_members/mock_data/api_responses.js b/spec/frontend/invite_members/mock_data/api_responses.js index dd84b4fd78f..a3e426376d8 100644 --- a/spec/frontend/invite_members/mock_data/api_responses.js +++ b/spec/frontend/invite_members/mock_data/api_responses.js @@ -26,7 +26,7 @@ const INVITATIONS_API_MULTIPLE_EMAIL_RESTRICTED = { const INVITATIONS_API_EMAIL_TAKEN = { message: { - 'email@example2.com': 'Invite email has already been taken', + 'email@example.org': 'Invite email has already been taken', }, status: 'error', }; diff --git a/spec/frontend/issuable/components/related_issuable_item_spec.js b/spec/frontend/issuable/components/related_issuable_item_spec.js index 6ac4c9e8546..6a896ccd21a 100644 --- a/spec/frontend/issuable/components/related_issuable_item_spec.js +++ b/spec/frontend/issuable/components/related_issuable_item_spec.js @@ -169,6 +169,8 @@ describe('RelatedIssuableItem', () => { }); it('renders disabled button when removeDisabled', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ removeDisabled: true }); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/create_merge_request_dropdown_spec.js b/spec/frontend/issues/create_merge_request_dropdown_spec.js index 9f07eea433a..fdc0bd7d72e 100644 --- a/spec/frontend/create_merge_request_dropdown_spec.js +++ b/spec/frontend/issues/create_merge_request_dropdown_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; import confidentialState from '~/confidential_merge_request/state'; -import CreateMergeRequestDropdown from '~/create_merge_request_dropdown'; +import CreateMergeRequestDropdown from '~/issues/create_merge_request_dropdown'; import axios from '~/lib/utils/axios_utils'; describe('CreateMergeRequestDropdown', () => { diff --git a/spec/frontend/issues_list/components/issue_card_time_info_spec.js b/spec/frontend/issues/list/components/issue_card_time_info_spec.js index 7c5faeb8dc1..e9c48b60da4 100644 --- a/spec/frontend/issues_list/components/issue_card_time_info_spec.js +++ b/spec/frontend/issues/list/components/issue_card_time_info_spec.js @@ -1,7 +1,7 @@ import { GlIcon, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { useFakeDate } from 'helpers/fake_date'; -import IssueCardTimeInfo from '~/issues_list/components/issue_card_time_info.vue'; +import IssueCardTimeInfo from '~/issues/list/components/issue_card_time_info.vue'; describe('CE IssueCardTimeInfo component', () => { useFakeDate(2020, 11, 11); 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 f24c090fa92..66428ee0492 100644 --- a/spec/frontend/issues_list/components/issues_list_app_spec.js +++ b/spec/frontend/issues/list/components/issues_list_app_spec.js @@ -5,8 +5,8 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import { cloneDeep } from 'lodash'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql'; -import getIssuesCountsQuery from 'ee_else_ce/issues_list/queries/get_issues_counts.query.graphql'; +import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; +import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; @@ -17,15 +17,15 @@ import { filteredTokens, locationSearch, urlParams, -} from 'jest/issues_list/mock_data'; +} from 'jest/issues/list/mock_data'; import createFlash, { FLASH_TYPES } from '~/flash'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants'; -import IssuesListApp from '~/issues_list/components/issues_list_app.vue'; -import NewIssueDropdown from '~/issues_list/components/new_issue_dropdown.vue'; +import IssuesListApp from '~/issues/list/components/issues_list_app.vue'; +import NewIssueDropdown from '~/issues/list/components/new_issue_dropdown.vue'; import { CREATED_DESC, DUE_DATE_OVERDUE, @@ -41,9 +41,9 @@ import { TOKEN_TYPE_RELEASE, TOKEN_TYPE_TYPE, urlSortParams, -} from '~/issues_list/constants'; -import eventHub from '~/issues_list/eventhub'; -import { getSortOptions } from '~/issues_list/utils'; +} from '~/issues/list/constants'; +import eventHub from '~/issues/list/eventhub'; +import { getSortOptions } from '~/issues/list/utils'; import axios from '~/lib/utils/axios_utils'; import { scrollUp } from '~/lib/utils/scroll_utils'; import { joinPaths } from '~/lib/utils/url_utility'; diff --git a/spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js b/spec/frontend/issues/list/components/jira_issues_import_status_app_spec.js index 633799816d8..d6d6bb14e9d 100644 --- a/spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js +++ b/spec/frontend/issues/list/components/jira_issues_import_status_app_spec.js @@ -1,7 +1,7 @@ import { GlAlert, GlLabel } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; -import JiraIssuesImportStatus from '~/issues_list/components/jira_issues_import_status_app.vue'; +import JiraIssuesImportStatus from '~/issues/list/components/jira_issues_import_status_app.vue'; describe('JiraIssuesImportStatus', () => { const issuesPath = 'gitlab-org/gitlab-test/-/issues'; diff --git a/spec/frontend/issues_list/components/new_issue_dropdown_spec.js b/spec/frontend/issues/list/components/new_issue_dropdown_spec.js index 1c9a87e8af2..0c52e66ff14 100644 --- a/spec/frontend/issues_list/components/new_issue_dropdown_spec.js +++ b/spec/frontend/issues/list/components/new_issue_dropdown_spec.js @@ -2,8 +2,8 @@ import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; -import NewIssueDropdown from '~/issues_list/components/new_issue_dropdown.vue'; -import searchProjectsQuery from '~/issues_list/queries/search_projects.query.graphql'; +import NewIssueDropdown from '~/issues/list/components/new_issue_dropdown.vue'; +import searchProjectsQuery from '~/issues/list/queries/search_projects.query.graphql'; import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility'; import { emptySearchProjectsQueryResponse, diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues/list/mock_data.js index 948699876ce..948699876ce 100644 --- a/spec/frontend/issues_list/mock_data.js +++ b/spec/frontend/issues/list/mock_data.js diff --git a/spec/frontend/issues_list/utils_spec.js b/spec/frontend/issues/list/utils_spec.js index 8e1d70db92d..0e4979fd7b4 100644 --- a/spec/frontend/issues_list/utils_spec.js +++ b/spec/frontend/issues/list/utils_spec.js @@ -7,14 +7,14 @@ import { locationSearchWithSpecialValues, urlParams, urlParamsWithSpecialValues, -} from 'jest/issues_list/mock_data'; +} from 'jest/issues/list/mock_data'; import { defaultPageSizeParams, DUE_DATE_VALUES, largePageSizeParams, RELATIVE_POSITION_ASC, urlSortParams, -} from '~/issues_list/constants'; +} from '~/issues/list/constants'; import { convertToApiParams, convertToSearchQuery, @@ -24,7 +24,7 @@ import { getInitialPageParams, getSortKey, getSortOptions, -} from '~/issues_list/utils'; +} from '~/issues/list/utils'; describe('getInitialPageParams', () => { it.each(Object.keys(urlSortParams))( diff --git a/spec/frontend/issues/new/components/title_suggestions_spec.js b/spec/frontend/issues/new/components/title_suggestions_spec.js index 984d0c9d25b..f6b93cc5a62 100644 --- a/spec/frontend/issues/new/components/title_suggestions_spec.js +++ b/spec/frontend/issues/new/components/title_suggestions_spec.js @@ -38,6 +38,8 @@ describe('Issue title suggestions component', () => { }); it('renders component', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData(data); return wrapper.vm.$nextTick(() => { @@ -47,6 +49,8 @@ describe('Issue title suggestions component', () => { it('does not render with empty search', () => { wrapper.setProps({ search: '' }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData(data); return wrapper.vm.$nextTick(() => { @@ -55,6 +59,8 @@ describe('Issue title suggestions component', () => { }); it('does not render when loading', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ ...data, loading: 1, @@ -66,6 +72,8 @@ describe('Issue title suggestions component', () => { }); it('does not render with empty issues data', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ issues: [] }); return wrapper.vm.$nextTick(() => { @@ -74,6 +82,8 @@ describe('Issue title suggestions component', () => { }); it('renders list of issues', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData(data); return wrapper.vm.$nextTick(() => { @@ -82,6 +92,8 @@ describe('Issue title suggestions component', () => { }); it('adds margin class to first item', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData(data); return wrapper.vm.$nextTick(() => { @@ -90,6 +102,8 @@ describe('Issue title suggestions component', () => { }); it('does not add margin class to last item', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData(data); return wrapper.vm.$nextTick(() => { diff --git a/spec/frontend/issues/show/components/fields/type_spec.js b/spec/frontend/issues/show/components/fields/type_spec.js index 3ece10e70db..7f7b16583e6 100644 --- a/spec/frontend/issues/show/components/fields/type_spec.js +++ b/spec/frontend/issues/show/components/fields/type_spec.js @@ -4,7 +4,7 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import IssueTypeField, { i18n } from '~/issues/show/components/fields/type.vue'; -import { IssuableTypes } from '~/issues/show/constants'; +import { issuableTypes } from '~/issues/show/constants'; import { getIssueStateQueryResponse, updateIssueStateQueryResponse, @@ -69,8 +69,8 @@ describe('Issue type field component', () => { it.each` at | text | icon - ${0} | ${IssuableTypes[0].text} | ${IssuableTypes[0].icon} - ${1} | ${IssuableTypes[1].text} | ${IssuableTypes[1].icon} + ${0} | ${issuableTypes[0].text} | ${issuableTypes[0].icon} + ${1} | ${issuableTypes[1].text} | ${issuableTypes[1].icon} `(`renders the issue type $text with an icon in the dropdown`, ({ at, text, icon }) => { expect(findTypeFromDropDownItemIconAt(at).attributes('name')).toBe(icon); expect(findTypeFromDropDownItemAt(at).text()).toBe(text); @@ -81,20 +81,20 @@ describe('Issue type field component', () => { }); it('renders a form select with the `issue_type` value', () => { - expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue); + expect(findTypeFromDropDown().attributes('value')).toBe(issuableTypes.issue); }); describe('with Apollo cache mock', () => { it('renders the selected issueType', async () => { mockIssueStateData.mockResolvedValue(getIssueStateQueryResponse); await waitForPromises(); - expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue); + expect(findTypeFromDropDown().attributes('value')).toBe(issuableTypes.issue); }); it('updates the `issue_type` in the apollo cache when the value is changed', async () => { - findTypeFromDropDownItems().at(1).vm.$emit('click', IssuableTypes.incident); + findTypeFromDropDownItems().at(1).vm.$emit('click', issuableTypes.incident); await wrapper.vm.$nextTick(); - expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.incident); + expect(findTypeFromDropDown().attributes('value')).toBe(issuableTypes.incident); }); describe('when user is a guest', () => { @@ -104,7 +104,7 @@ describe('Issue type field component', () => { expect(findTypeFromDropDownItemAt(0).isVisible()).toBe(true); expect(findTypeFromDropDownItemAt(1).isVisible()).toBe(false); - expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue); + expect(findTypeFromDropDown().attributes('value')).toBe(issuableTypes.issue); }); it('and incident is selected, includes incident in the dropdown', async () => { @@ -113,7 +113,7 @@ describe('Issue type field component', () => { expect(findTypeFromDropDownItemAt(0).isVisible()).toBe(true); expect(findTypeFromDropDownItemAt(1).isVisible()).toBe(true); - expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.incident); + expect(findTypeFromDropDown().attributes('value')).toBe(issuableTypes.incident); }); }); }); diff --git a/spec/frontend/issues/show/components/header_actions_spec.js b/spec/frontend/issues/show/components/header_actions_spec.js index 2a16c699c4d..d09bf6faa13 100644 --- a/spec/frontend/issues/show/components/header_actions_spec.js +++ b/spec/frontend/issues/show/components/header_actions_spec.js @@ -4,11 +4,10 @@ import { shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; import { mockTracking } from 'helpers/tracking_helper'; import createFlash, { FLASH_TYPES } from '~/flash'; -import { IssuableType } from '~/vue_shared/issuable/show/constants'; +import { IssuableStatus, IssueType } from '~/issues/constants'; import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue'; import HeaderActions from '~/issues/show/components/header_actions.vue'; -import { IssuableStatus } from '~/issues/constants'; -import { IssueStateEvent } from '~/issues/show/constants'; +import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants'; import promoteToEpicMutation from '~/issues/show/queries/promote_to_epic.mutation.graphql'; import * as urlUtility from '~/lib/utils/url_utility'; import eventHub from '~/notes/event_hub'; @@ -36,7 +35,7 @@ describe('HeaderActions component', () => { iid: '32', isIssueAuthor: true, issuePath: 'gitlab-org/gitlab-test/-/issues/1', - issueType: IssuableType.Issue, + issueType: IssueType.Issue, newIssuePath: 'gitlab-org/gitlab-test/-/issues/new', projectPath: 'gitlab-org/gitlab-test', reportAbusePath: @@ -112,14 +111,14 @@ describe('HeaderActions component', () => { describe.each` issueType - ${IssuableType.Issue} - ${IssuableType.Incident} + ${IssueType.Issue} + ${IssueType.Incident} `('when issue type is $issueType', ({ issueType }) => { describe('close/reopen button', () => { describe.each` description | issueState | buttonText | newIssueState - ${`when the ${issueType} is open`} | ${IssuableStatus.Open} | ${`Close ${issueType}`} | ${IssueStateEvent.Close} - ${`when the ${issueType} is closed`} | ${IssuableStatus.Closed} | ${`Reopen ${issueType}`} | ${IssueStateEvent.Reopen} + ${`when the ${issueType} is open`} | ${IssuableStatus.Open} | ${`Close ${issueType}`} | ${ISSUE_STATE_EVENT_CLOSE} + ${`when the ${issueType} is closed`} | ${IssuableStatus.Closed} | ${`Reopen ${issueType}`} | ${ISSUE_STATE_EVENT_REOPEN} `('$description', ({ issueState, buttonText, newIssueState }) => { beforeEach(() => { dispatchEventSpy = jest.spyOn(document, 'dispatchEvent'); @@ -306,7 +305,7 @@ describe('HeaderActions component', () => { input: { iid: defaultProps.iid, projectPath: defaultProps.projectPath, - stateEvent: IssueStateEvent.Close, + stateEvent: ISSUE_STATE_EVENT_CLOSE, }, }, }), @@ -345,7 +344,7 @@ describe('HeaderActions component', () => { input: { iid: defaultProps.iid.toString(), projectPath: defaultProps.projectPath, - stateEvent: IssueStateEvent.Close, + stateEvent: ISSUE_STATE_EVENT_CLOSE, }, }, }), diff --git a/spec/frontend/issues/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js b/spec/frontend/issues/show/components/sentry_error_stack_trace_spec.js index 5a51ae3cfe0..b38d2b60057 100644 --- a/spec/frontend/issues/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js +++ b/spec/frontend/issues/show/components/sentry_error_stack_trace_spec.js @@ -1,11 +1,9 @@ +import Vue from 'vue'; import { GlLoadingIcon } from '@gitlab/ui'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; import Stacktrace from '~/error_tracking/components/stacktrace.vue'; -import SentryErrorStackTrace from '~/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue'; - -const localVue = createLocalVue(); -localVue.use(Vuex); +import SentryErrorStackTrace from '~/issues/show/components/sentry_error_stack_trace.vue'; describe('Sentry Error Stack Trace', () => { let actions; @@ -13,13 +11,14 @@ describe('Sentry Error Stack Trace', () => { let store; let wrapper; + Vue.use(Vuex); + function mountComponent({ stubs = { stacktrace: Stacktrace, }, } = {}) { wrapper = shallowMount(SentryErrorStackTrace, { - localVue, stubs, store, propsData: { diff --git a/spec/frontend/issues/show/issue_spec.js b/spec/frontend/issues/show/issue_spec.js index 6d7a31a6c8c..68c2e3768c7 100644 --- a/spec/frontend/issues/show/issue_spec.js +++ b/spec/frontend/issues/show/issue_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; -import { initIssuableApp } from '~/issues/show/issue'; +import { initIssueApp } from '~/issues/show'; import * as parseData from '~/issues/show/utils/parse_data'; import axios from '~/lib/utils/axios_utils'; import createStore from '~/notes/stores'; @@ -17,7 +17,7 @@ const setupHTML = (initialData) => { }; describe('Issue show index', () => { - describe('initIssuableApp', () => { + describe('initIssueApp', () => { it('should initialize app with no potential XSS attack', async () => { const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {}); const parseDataSpy = jest.spyOn(parseData, 'parseIssuableData'); @@ -29,7 +29,7 @@ describe('Issue show index', () => { const initialDataEl = document.getElementById('js-issuable-app'); const issuableData = parseData.parseIssuableData(initialDataEl); - initIssuableApp(issuableData, createStore()); + initIssueApp(issuableData, createStore()); await waitForPromises(); diff --git a/spec/frontend/issues_list/components/__snapshots__/issuables_list_app_spec.js.snap b/spec/frontend/issues_list/components/__snapshots__/issuables_list_app_spec.js.snap deleted file mode 100644 index c327b7de827..00000000000 --- a/spec/frontend/issues_list/components/__snapshots__/issuables_list_app_spec.js.snap +++ /dev/null @@ -1,14 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Issuables list component with empty issues response with all state should display a catch-all if there are no issues to show 1`] = ` -<gl-empty-state-stub - svgpath="/emptySvg" - title="There are no issues to show" -/> -`; - -exports[`Issuables list component with empty issues response with closed state should display a message "There are no closed issues" if there are no closed issues 1`] = `"There are no closed issues"`; - -exports[`Issuables list component with empty issues response with empty query should display the message "There are no open issues" 1`] = `"There are no open issues"`; - -exports[`Issuables list component with empty issues response with query in window location should display "Sorry, your filter produced no results" if filters are too specific 1`] = `"Sorry, your filter produced no results"`; diff --git a/spec/frontend/issues_list/components/issuable_spec.js b/spec/frontend/issues_list/components/issuable_spec.js deleted file mode 100644 index f3c2ae1f9dc..00000000000 --- a/spec/frontend/issues_list/components/issuable_spec.js +++ /dev/null @@ -1,508 +0,0 @@ -import { GlSprintf, GlLabel, GlIcon, GlLink } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { TEST_HOST } from 'helpers/test_constants'; -import { trimText } from 'helpers/text_helper'; -import Issuable from '~/issues_list/components/issuable.vue'; -import { isScopedLabel } from '~/lib/utils/common_utils'; -import { formatDate } from '~/lib/utils/datetime_utility'; -import { mergeUrlParams } from '~/lib/utils/url_utility'; -import initUserPopovers from '~/user_popovers'; -import IssueAssignees from '~/issuable/components/issue_assignees.vue'; -import { simpleIssue, testAssignees, testLabels } from '../issuable_list_test_data'; - -jest.mock('~/user_popovers'); - -const TODAY = new Date(); - -const createTestDateFromDelta = (timeDelta) => - formatDate(new Date(TODAY.getTime() + timeDelta), 'yyyy-mm-dd'); - -// TODO: Encapsulate date helpers https://gitlab.com/gitlab-org/gitlab/-/issues/320883 -const MONTHS_IN_MS = 1000 * 60 * 60 * 24 * 31; -const TEST_MONTH_AGO = createTestDateFromDelta(-MONTHS_IN_MS); -const TEST_MONTH_LATER = createTestDateFromDelta(MONTHS_IN_MS); -const DATE_FORMAT = 'mmm d, yyyy'; -const TEST_USER_NAME = 'Tyler Durden'; -const TEST_BASE_URL = `${TEST_HOST}/issues`; -const TEST_TASK_STATUS = '50 of 100 tasks completed'; -const TEST_MILESTONE = { - title: 'Milestone title', - web_url: `${TEST_HOST}/milestone/1`, -}; -const TEXT_CLOSED = 'CLOSED'; -const TEST_META_COUNT = 100; -const MOCK_GITLAB_URL = 'http://0.0.0.0:3000'; - -describe('Issuable component', () => { - let issuable; - let wrapper; - - const factory = (props = {}, scopedLabelsAvailable = false) => { - wrapper = shallowMount(Issuable, { - propsData: { - issuable: simpleIssue, - baseUrl: TEST_BASE_URL, - ...props, - }, - provide: { - scopedLabelsAvailable, - }, - stubs: { - 'gl-sprintf': GlSprintf, - }, - }); - }; - - beforeEach(() => { - issuable = { ...simpleIssue }; - gon.gitlab_url = MOCK_GITLAB_URL; - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - const checkExists = (findFn) => () => findFn().exists(); - const hasIcon = (iconName, iconWrapper = wrapper) => - iconWrapper.findAll(GlIcon).wrappers.some((icon) => icon.props('name') === iconName); - const hasConfidentialIcon = () => hasIcon('eye-slash'); - const findTaskStatus = () => wrapper.find('.task-status'); - const findOpenedAgoContainer = () => wrapper.find('[data-testid="openedByMessage"]'); - const findAuthor = () => wrapper.find({ ref: 'openedAgoByContainer' }); - const findMilestone = () => wrapper.find('.js-milestone'); - const findMilestoneTooltip = () => findMilestone().attributes('title'); - const findDueDate = () => wrapper.find('.js-due-date'); - const findLabels = () => wrapper.findAll(GlLabel); - const findWeight = () => wrapper.find('[data-testid="weight"]'); - const findAssignees = () => wrapper.find(IssueAssignees); - const findBlockingIssuesCount = () => wrapper.find('[data-testid="blocking-issues"]'); - const findMergeRequestsCount = () => wrapper.find('[data-testid="merge-requests"]'); - const findUpvotes = () => wrapper.find('[data-testid="upvotes"]'); - const findDownvotes = () => wrapper.find('[data-testid="downvotes"]'); - const findNotes = () => wrapper.find('[data-testid="notes-count"]'); - const findBulkCheckbox = () => wrapper.find('input.selected-issuable'); - const findScopedLabels = () => findLabels().filter((w) => isScopedLabel({ title: w.text() })); - const findUnscopedLabels = () => findLabels().filter((w) => !isScopedLabel({ title: w.text() })); - const findIssuableTitle = () => wrapper.find('[data-testid="issuable-title"]'); - const findIssuableStatus = () => wrapper.find('[data-testid="issuable-status"]'); - const containsJiraLogo = () => wrapper.find('[data-testid="jira-logo"]').exists(); - const findHealthStatus = () => wrapper.find('.health-status'); - - describe('when mounted', () => { - it('initializes user popovers', () => { - expect(initUserPopovers).not.toHaveBeenCalled(); - - factory(); - - expect(initUserPopovers).toHaveBeenCalledWith([wrapper.vm.$refs.openedAgoByContainer.$el]); - }); - }); - - describe('when scopedLabels feature is available', () => { - beforeEach(() => { - issuable.labels = [...testLabels]; - - factory({ issuable }, true); - }); - - describe('when label is scoped', () => { - it('returns label with correct props', () => { - const scopedLabel = findScopedLabels().at(0); - - expect(scopedLabel.props('scoped')).toBe(true); - }); - }); - - describe('when label is not scoped', () => { - it('returns label with correct props', () => { - const notScopedLabel = findUnscopedLabels().at(0); - - expect(notScopedLabel.props('scoped')).toBe(false); - }); - }); - }); - - describe('when scopedLabels feature is not available', () => { - beforeEach(() => { - issuable.labels = [...testLabels]; - - factory({ issuable }); - }); - - describe('when label is scoped', () => { - it('label scoped props is false', () => { - const scopedLabel = findScopedLabels().at(0); - - expect(scopedLabel.props('scoped')).toBe(false); - }); - }); - - describe('when label is not scoped', () => { - it('label scoped props is false', () => { - const notScopedLabel = findUnscopedLabels().at(0); - - expect(notScopedLabel.props('scoped')).toBe(false); - }); - }); - }); - - describe('with simple issuable', () => { - beforeEach(() => { - Object.assign(issuable, { - has_tasks: false, - task_status: TEST_TASK_STATUS, - created_at: TEST_MONTH_AGO, - author: { - ...issuable.author, - name: TEST_USER_NAME, - }, - labels: [], - }); - - factory({ issuable }); - }); - - it.each` - desc | check - ${'bulk editing checkbox'} | ${checkExists(findBulkCheckbox)} - ${'confidential icon'} | ${hasConfidentialIcon} - ${'task status'} | ${checkExists(findTaskStatus)} - ${'milestone'} | ${checkExists(findMilestone)} - ${'due date'} | ${checkExists(findDueDate)} - ${'labels'} | ${checkExists(findLabels)} - ${'weight'} | ${checkExists(findWeight)} - ${'blocking issues count'} | ${checkExists(findBlockingIssuesCount)} - ${'merge request count'} | ${checkExists(findMergeRequestsCount)} - ${'upvotes'} | ${checkExists(findUpvotes)} - ${'downvotes'} | ${checkExists(findDownvotes)} - `('does not render $desc', ({ check }) => { - expect(check()).toBe(false); - }); - - it('show relative reference path', () => { - expect(wrapper.find('.js-ref-path').text()).toBe(issuable.references.relative); - }); - - it('does not have closed text', () => { - expect(wrapper.text()).not.toContain(TEXT_CLOSED); - }); - - it('does not have closed class', () => { - expect(wrapper.classes('closed')).toBe(false); - }); - - it('renders fuzzy created date and author', () => { - expect(trimText(findOpenedAgoContainer().text())).toContain( - `created 1 month ago by ${TEST_USER_NAME}`, - ); - }); - - it('renders no comments', () => { - expect(findNotes().classes('no-comments')).toBe(true); - }); - - it.each` - gitlabWebUrl | webUrl | expectedHref | expectedTarget | isExternal - ${undefined} | ${`${MOCK_GITLAB_URL}/issue`} | ${`${MOCK_GITLAB_URL}/issue`} | ${undefined} | ${false} - ${undefined} | ${'https://jira.com/issue'} | ${'https://jira.com/issue'} | ${'_blank'} | ${true} - ${'/gitlab-org/issue'} | ${'https://jira.com/issue'} | ${'/gitlab-org/issue'} | ${undefined} | ${false} - `( - 'renders issuable title correctly when `gitlabWebUrl` is `$gitlabWebUrl` and webUrl is `$webUrl`', - async ({ webUrl, gitlabWebUrl, expectedHref, expectedTarget, isExternal }) => { - factory({ - issuable: { - ...issuable, - web_url: webUrl, - gitlab_web_url: gitlabWebUrl, - }, - }); - - const titleEl = findIssuableTitle(); - - expect(titleEl.exists()).toBe(true); - expect(titleEl.find(GlLink).attributes('href')).toBe(expectedHref); - expect(titleEl.find(GlLink).attributes('target')).toBe(expectedTarget); - expect(titleEl.find(GlLink).text()).toBe(issuable.title); - - expect(titleEl.find(GlIcon).exists()).toBe(isExternal); - }, - ); - }); - - describe('with confidential issuable', () => { - beforeEach(() => { - issuable.confidential = true; - - factory({ issuable }); - }); - - it('renders the confidential icon', () => { - expect(hasConfidentialIcon()).toBe(true); - }); - }); - - describe('with Jira issuable', () => { - beforeEach(() => { - issuable.external_tracker = 'jira'; - - factory({ issuable }); - }); - - it('renders the Jira icon', () => { - expect(containsJiraLogo()).toBe(true); - }); - - it('opens issuable in a new tab', () => { - expect(findIssuableTitle().props('target')).toBe('_blank'); - }); - - it('opens author in a new tab', () => { - expect(findAuthor().props('target')).toBe('_blank'); - }); - - describe('with Jira status', () => { - const expectedStatus = 'In Progress'; - - beforeEach(() => { - issuable.status = expectedStatus; - - factory({ issuable }); - }); - - it('renders the Jira status', () => { - expect(findIssuableStatus().text()).toBe(expectedStatus); - }); - }); - }); - - describe('with task status', () => { - beforeEach(() => { - Object.assign(issuable, { - has_tasks: true, - task_status: TEST_TASK_STATUS, - }); - - factory({ issuable }); - }); - - it('renders task status', () => { - expect(findTaskStatus().exists()).toBe(true); - expect(findTaskStatus().text()).toBe(TEST_TASK_STATUS); - }); - }); - - describe.each` - desc | dueDate | expectedTooltipPart - ${'past due'} | ${TEST_MONTH_AGO} | ${'Past due'} - ${'future due'} | ${TEST_MONTH_LATER} | ${'1 month remaining'} - `('with milestone with $desc', ({ dueDate, expectedTooltipPart }) => { - beforeEach(() => { - issuable.milestone = { ...TEST_MILESTONE, due_date: dueDate }; - - factory({ issuable }); - }); - - it('renders milestone', () => { - expect(findMilestone().exists()).toBe(true); - expect(hasIcon('clock', findMilestone())).toBe(true); - expect(findMilestone().text()).toEqual(TEST_MILESTONE.title); - }); - - it('renders tooltip', () => { - expect(findMilestoneTooltip()).toBe( - `${formatDate(dueDate, DATE_FORMAT)} (${expectedTooltipPart})`, - ); - }); - - it('renders milestone with the correct href', () => { - const { title } = issuable.milestone; - const expected = mergeUrlParams({ milestone_title: title }, TEST_BASE_URL); - - expect(findMilestone().attributes('href')).toBe(expected); - }); - }); - - describe.each` - dueDate | hasClass | desc - ${TEST_MONTH_LATER} | ${false} | ${'with future due date'} - ${TEST_MONTH_AGO} | ${true} | ${'with past due date'} - `('$desc', ({ dueDate, hasClass }) => { - beforeEach(() => { - issuable.due_date = dueDate; - - factory({ issuable }); - }); - - it('renders due date', () => { - expect(findDueDate().exists()).toBe(true); - expect(findDueDate().text()).toBe(formatDate(dueDate, DATE_FORMAT)); - }); - - it(hasClass ? 'has cred class' : 'does not have cred class', () => { - expect(findDueDate().classes('cred')).toEqual(hasClass); - }); - }); - - describe('with labels', () => { - beforeEach(() => { - issuable.labels = [...testLabels]; - - factory({ issuable }); - }); - - it('renders labels', () => { - factory({ issuable }); - - const labels = findLabels().wrappers.map((label) => ({ - href: label.props('target'), - text: label.text(), - tooltip: label.attributes('description'), - })); - - const expected = testLabels.map((label) => ({ - href: mergeUrlParams({ 'label_name[]': label.name }, TEST_BASE_URL), - text: label.name, - tooltip: label.description, - })); - - expect(labels).toEqual(expected); - }); - }); - - describe('with labels for Jira issuable', () => { - beforeEach(() => { - issuable.labels = [...testLabels]; - issuable.external_tracker = 'jira'; - - factory({ issuable }); - }); - - it('renders labels', () => { - factory({ issuable }); - - const labels = findLabels().wrappers.map((label) => ({ - href: label.props('target'), - text: label.text(), - tooltip: label.attributes('description'), - })); - - const expected = testLabels.map((label) => ({ - href: mergeUrlParams({ 'labels[]': label.name }, TEST_BASE_URL), - text: label.name, - tooltip: label.description, - })); - - expect(labels).toEqual(expected); - }); - }); - - describe.each` - weight - ${0} - ${10} - ${12345} - `('with weight $weight', ({ weight }) => { - beforeEach(() => { - issuable.weight = weight; - - factory({ issuable }); - }); - - it('renders weight', () => { - expect(findWeight().exists()).toBe(true); - expect(findWeight().text()).toEqual(weight.toString()); - }); - }); - - describe('with closed state', () => { - beforeEach(() => { - issuable.state = 'closed'; - - factory({ issuable }); - }); - - it('renders closed text', () => { - expect(wrapper.text()).toContain(TEXT_CLOSED); - }); - - it('has closed class', () => { - expect(wrapper.classes('closed')).toBe(true); - }); - }); - - describe('with assignees', () => { - beforeEach(() => { - issuable.assignees = testAssignees; - - factory({ issuable }); - }); - - it('renders assignees', () => { - expect(findAssignees().exists()).toBe(true); - expect(findAssignees().props('assignees')).toEqual(testAssignees); - }); - }); - - describe.each` - desc | key | finder - ${'with blocking issues count'} | ${'blocking_issues_count'} | ${findBlockingIssuesCount} - ${'with merge requests count'} | ${'merge_requests_count'} | ${findMergeRequestsCount} - ${'with upvote count'} | ${'upvotes'} | ${findUpvotes} - ${'with downvote count'} | ${'downvotes'} | ${findDownvotes} - ${'with notes count'} | ${'user_notes_count'} | ${findNotes} - `('$desc', ({ key, finder }) => { - beforeEach(() => { - issuable[key] = TEST_META_COUNT; - - factory({ issuable }); - }); - - it('renders correct count', () => { - expect(finder().exists()).toBe(true); - expect(finder().text()).toBe(TEST_META_COUNT.toString()); - expect(finder().classes('no-comments')).toBe(false); - }); - }); - - describe('with bulk editing', () => { - describe.each` - selected | desc - ${true} | ${'when selected'} - ${false} | ${'when unselected'} - `('$desc', ({ selected }) => { - beforeEach(() => { - factory({ isBulkEditing: true, selected }); - }); - - it(`renders checked is ${selected}`, () => { - expect(findBulkCheckbox().element.checked).toBe(selected); - }); - - it('emits select when clicked', () => { - expect(wrapper.emitted().select).toBeUndefined(); - - findBulkCheckbox().trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted().select).toEqual([[{ issuable, selected: !selected }]]); - }); - }); - }); - }); - - if (IS_EE) { - describe('with health status', () => { - it('renders health status tag', () => { - factory({ issuable }); - expect(findHealthStatus().exists()).toBe(true); - }); - - it('does not render when health status is absent', () => { - issuable.health_status = null; - factory({ issuable }); - expect(findHealthStatus().exists()).toBe(false); - }); - }); - } -}); diff --git a/spec/frontend/issues_list/components/issuables_list_app_spec.js b/spec/frontend/issues_list/components/issuables_list_app_spec.js deleted file mode 100644 index 11854db534e..00000000000 --- a/spec/frontend/issues_list/components/issuables_list_app_spec.js +++ /dev/null @@ -1,653 +0,0 @@ -import { - GlEmptyState, - GlPagination, - GlDeprecatedSkeletonLoading as GlSkeletonLoading, -} from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; -import setWindowLocation from 'helpers/set_window_location_helper'; -import { TEST_HOST } from 'helpers/test_constants'; -import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; -import Issuable from '~/issues_list/components/issuable.vue'; -import IssuablesListApp from '~/issues_list/components/issuables_list_app.vue'; -import { PAGE_SIZE, PAGE_SIZE_MANUAL, RELATIVE_POSITION } from '~/issues_list/constants'; -import issuablesEventBus from '~/issues_list/eventhub'; -import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; - -jest.mock('~/flash'); -jest.mock('~/issues_list/eventhub'); -jest.mock('~/lib/utils/common_utils', () => ({ - ...jest.requireActual('~/lib/utils/common_utils'), - scrollToElement: () => {}, -})); - -const TEST_LOCATION = `${TEST_HOST}/issues`; -const TEST_ENDPOINT = '/issues'; -const TEST_CREATE_ISSUES_PATH = '/createIssue'; -const TEST_SVG_PATH = '/emptySvg'; - -const MOCK_ISSUES = Array(PAGE_SIZE_MANUAL) - .fill(0) - .map((_, i) => ({ - id: i, - web_url: `url${i}`, - })); - -describe('Issuables list component', () => { - let mockAxios; - let wrapper; - let apiSpy; - - const setupApiMock = (cb) => { - apiSpy = jest.fn(cb); - - mockAxios.onGet(TEST_ENDPOINT).reply((cfg) => apiSpy(cfg)); - }; - - const factory = (props = { sortKey: 'priority' }) => { - const emptyStateMeta = { - createIssuePath: TEST_CREATE_ISSUES_PATH, - svgPath: TEST_SVG_PATH, - }; - - wrapper = shallowMount(IssuablesListApp, { - propsData: { - endpoint: TEST_ENDPOINT, - emptyStateMeta, - ...props, - }, - }); - }; - - const findLoading = () => wrapper.find(GlSkeletonLoading); - const findIssuables = () => wrapper.findAll(Issuable); - const findFilteredSearchBar = () => wrapper.find(FilteredSearchBar); - const findFirstIssuable = () => findIssuables().wrappers[0]; - const findEmptyState = () => wrapper.find(GlEmptyState); - - beforeEach(() => { - mockAxios = new MockAdapter(axios); - - setWindowLocation(TEST_LOCATION); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - mockAxios.restore(); - }); - - describe('with failed issues response', () => { - beforeEach(() => { - setupApiMock(() => [500]); - - factory(); - - return waitForPromises(); - }); - - it('does not show loading', () => { - expect(wrapper.vm.loading).toBe(false); - }); - - it('flashes an error', () => { - expect(createFlash).toHaveBeenCalledTimes(1); - }); - }); - - describe('with successful issues response', () => { - beforeEach(() => { - setupApiMock(() => [ - 200, - MOCK_ISSUES.slice(0, PAGE_SIZE), - { - 'x-total': 100, - 'x-page': 2, - }, - ]); - }); - - it('has default props and data', () => { - factory(); - expect(wrapper.vm).toMatchObject({ - // Props - canBulkEdit: false, - emptyStateMeta: { - createIssuePath: TEST_CREATE_ISSUES_PATH, - svgPath: TEST_SVG_PATH, - }, - // Data - filters: { - state: 'opened', - }, - isBulkEditing: false, - issuables: [], - loading: true, - page: 1, - selection: {}, - totalItems: 0, - }); - }); - - it('does not call API until mounted', () => { - factory(); - expect(apiSpy).not.toHaveBeenCalled(); - }); - - describe('when mounted', () => { - beforeEach(() => { - factory(); - }); - - it('calls API', () => { - expect(apiSpy).toHaveBeenCalled(); - }); - - it('shows loading', () => { - expect(findLoading().exists()).toBe(true); - expect(findIssuables().length).toBe(0); - expect(findEmptyState().exists()).toBe(false); - }); - }); - - describe('when finished loading', () => { - beforeEach(() => { - factory(); - - return waitForPromises(); - }); - - it('does not display empty state', () => { - expect(wrapper.vm.issuables.length).toBeGreaterThan(0); - expect(wrapper.vm.emptyState).toEqual({}); - expect(wrapper.find(GlEmptyState).exists()).toBe(false); - }); - - it('sets the proper page and total items', () => { - expect(wrapper.vm.totalItems).toBe(100); - expect(wrapper.vm.page).toBe(2); - }); - - it('renders one page of issuables and pagination', () => { - expect(findIssuables().length).toBe(PAGE_SIZE); - expect(wrapper.find(GlPagination).exists()).toBe(true); - }); - }); - - it('does not render FilteredSearchBar', () => { - factory(); - - expect(findFilteredSearchBar().exists()).toBe(false); - }); - }); - - describe('with bulk editing enabled', () => { - beforeEach(() => { - issuablesEventBus.$on.mockReset(); - issuablesEventBus.$emit.mockReset(); - - setupApiMock(() => [200, MOCK_ISSUES.slice(0)]); - factory({ canBulkEdit: true }); - - return waitForPromises(); - }); - - it('is not enabled by default', () => { - expect(wrapper.vm.isBulkEditing).toBe(false); - }); - - it('does not select issues by default', () => { - expect(wrapper.vm.selection).toEqual({}); - }); - - it('"Select All" checkbox toggles all visible issuables"', () => { - wrapper.vm.onSelectAll(); - expect(wrapper.vm.selection).toEqual( - wrapper.vm.issuables.reduce((acc, i) => ({ ...acc, [i.id]: true }), {}), - ); - - wrapper.vm.onSelectAll(); - expect(wrapper.vm.selection).toEqual({}); - }); - - it('"Select All checkbox" selects all issuables if only some are selected"', () => { - wrapper.vm.selection = { [wrapper.vm.issuables[0].id]: true }; - wrapper.vm.onSelectAll(); - expect(wrapper.vm.selection).toEqual( - wrapper.vm.issuables.reduce((acc, i) => ({ ...acc, [i.id]: true }), {}), - ); - }); - - it('selects and deselects issuables', () => { - const [i0, i1, i2] = wrapper.vm.issuables; - - expect(wrapper.vm.selection).toEqual({}); - wrapper.vm.onSelectIssuable({ issuable: i0, selected: false }); - expect(wrapper.vm.selection).toEqual({}); - wrapper.vm.onSelectIssuable({ issuable: i1, selected: true }); - expect(wrapper.vm.selection).toEqual({ 1: true }); - wrapper.vm.onSelectIssuable({ issuable: i0, selected: true }); - expect(wrapper.vm.selection).toEqual({ 1: true, 0: true }); - wrapper.vm.onSelectIssuable({ issuable: i2, selected: true }); - expect(wrapper.vm.selection).toEqual({ 1: true, 0: true, 2: true }); - wrapper.vm.onSelectIssuable({ issuable: i2, selected: true }); - expect(wrapper.vm.selection).toEqual({ 1: true, 0: true, 2: true }); - wrapper.vm.onSelectIssuable({ issuable: i0, selected: false }); - expect(wrapper.vm.selection).toEqual({ 1: true, 2: true }); - }); - - it('broadcasts a message to the bulk edit sidebar when a value is added to selection', () => { - issuablesEventBus.$emit.mockReset(); - const i1 = wrapper.vm.issuables[1]; - - wrapper.vm.onSelectIssuable({ issuable: i1, selected: true }); - - return wrapper.vm.$nextTick().then(() => { - expect(issuablesEventBus.$emit).toHaveBeenCalledTimes(1); - expect(issuablesEventBus.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit'); - }); - }); - - it('does not broadcast a message to the bulk edit sidebar when a value is not added to selection', () => { - issuablesEventBus.$emit.mockReset(); - - return wrapper.vm - .$nextTick() - .then(waitForPromises) - .then(() => { - const i1 = wrapper.vm.issuables[1]; - - wrapper.vm.onSelectIssuable({ issuable: i1, selected: false }); - }) - .then(wrapper.vm.$nextTick) - .then(() => { - expect(issuablesEventBus.$emit).toHaveBeenCalledTimes(0); - }); - }); - - it('listens to a message to toggle bulk editing', () => { - expect(wrapper.vm.isBulkEditing).toBe(false); - expect(issuablesEventBus.$on.mock.calls[0][0]).toBe('issuables:toggleBulkEdit'); - issuablesEventBus.$on.mock.calls[0][1](true); // Call the message handler - - return waitForPromises() - .then(() => { - expect(wrapper.vm.isBulkEditing).toBe(true); - issuablesEventBus.$on.mock.calls[0][1](false); - }) - .then(() => { - expect(wrapper.vm.isBulkEditing).toBe(false); - }); - }); - }); - - describe('with query params in window.location', () => { - const expectedFilters = { - assignee_username: 'root', - author_username: 'root', - confidential: 'yes', - my_reaction_emoji: 'airplane', - scope: 'all', - state: 'opened', - weight: '0', - milestone: 'v3.0', - labels: 'Aquapod,Astro', - order_by: 'milestone_due', - sort: 'desc', - }; - - describe('when page is not present in params', () => { - const query = - '?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&weight=0¬[label_name][]=Afterpod¬[milestone_title][]=13'; - - beforeEach(() => { - setWindowLocation(query); - - setupApiMock(() => [200, MOCK_ISSUES.slice(0)]); - factory({ sortKey: 'milestone_due_desc' }); - - return waitForPromises(); - }); - - afterEach(() => { - apiSpy.mockClear(); - }); - - it('applies filters and sorts', () => { - expect(wrapper.vm.hasFilters).toBe(true); - expect(wrapper.vm.filters).toEqual({ - ...expectedFilters, - 'not[milestone]': ['13'], - 'not[labels]': ['Afterpod'], - }); - - expect(apiSpy).toHaveBeenCalledWith( - expect.objectContaining({ - params: { - ...expectedFilters, - with_labels_details: true, - page: 1, - per_page: PAGE_SIZE, - 'not[milestone]': ['13'], - 'not[labels]': ['Afterpod'], - }, - }), - ); - }); - - it('passes the base url to issuable', () => { - expect(findFirstIssuable().props('baseUrl')).toBe(TEST_LOCATION); - }); - }); - - describe('when page is present in the param', () => { - const query = - '?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&weight=0&page=3'; - - beforeEach(() => { - setWindowLocation(query); - - setupApiMock(() => [200, MOCK_ISSUES.slice(0)]); - factory({ sortKey: 'milestone_due_desc' }); - - return waitForPromises(); - }); - - afterEach(() => { - apiSpy.mockClear(); - }); - - it('applies filters and sorts', () => { - expect(apiSpy).toHaveBeenCalledWith( - expect.objectContaining({ - params: { - ...expectedFilters, - with_labels_details: true, - page: 3, - per_page: PAGE_SIZE, - }, - }), - ); - }); - }); - }); - - describe('with hash in window.location', () => { - beforeEach(() => { - setWindowLocation(`${TEST_LOCATION}#stuff`); - setupApiMock(() => [200, MOCK_ISSUES.slice(0)]); - factory(); - return waitForPromises(); - }); - - it('passes the base url to issuable', () => { - expect(findFirstIssuable().props('baseUrl')).toBe(TEST_LOCATION); - }); - }); - - describe('with manual sort', () => { - beforeEach(() => { - setupApiMock(() => [200, MOCK_ISSUES.slice(0)]); - factory({ sortKey: RELATIVE_POSITION }); - }); - - it('uses manual page size', () => { - expect(apiSpy).toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ - per_page: PAGE_SIZE_MANUAL, - }), - }), - ); - }); - }); - - describe('with empty issues response', () => { - beforeEach(() => { - setupApiMock(() => [200, []]); - }); - - describe('with query in window location', () => { - beforeEach(() => { - setWindowLocation('?weight=Any'); - - factory(); - - return waitForPromises().then(() => wrapper.vm.$nextTick()); - }); - - it('should display "Sorry, your filter produced no results" if filters are too specific', () => { - expect(findEmptyState().props('title')).toMatchSnapshot(); - }); - }); - - describe('with closed state', () => { - beforeEach(() => { - setWindowLocation('?state=closed'); - - factory(); - - return waitForPromises().then(() => wrapper.vm.$nextTick()); - }); - - it('should display a message "There are no closed issues" if there are no closed issues', () => { - expect(findEmptyState().props('title')).toMatchSnapshot(); - }); - }); - - describe('with all state', () => { - beforeEach(() => { - setWindowLocation('?state=all'); - - factory(); - - return waitForPromises().then(() => wrapper.vm.$nextTick()); - }); - - it('should display a catch-all if there are no issues to show', () => { - expect(findEmptyState().element).toMatchSnapshot(); - }); - }); - - describe('with empty query', () => { - beforeEach(() => { - factory(); - - return wrapper.vm.$nextTick().then(waitForPromises); - }); - - it('should display the message "There are no open issues"', () => { - expect(findEmptyState().props('title')).toMatchSnapshot(); - }); - }); - }); - - describe('when paginates', () => { - const newPage = 3; - - describe('when total-items is defined in response headers', () => { - beforeEach(() => { - window.history.pushState = jest.fn(); - setupApiMock(() => [ - 200, - MOCK_ISSUES.slice(0, PAGE_SIZE), - { - 'x-total': 100, - 'x-page': 2, - }, - ]); - - factory(); - - return waitForPromises(); - }); - - afterEach(() => { - // reset to original value - window.history.pushState.mockRestore(); - }); - - it('calls window.history.pushState one time', () => { - // Trigger pagination - wrapper.find(GlPagination).vm.$emit('input', newPage); - - expect(window.history.pushState).toHaveBeenCalledTimes(1); - }); - - it('sets params in the url', () => { - // Trigger pagination - wrapper.find(GlPagination).vm.$emit('input', newPage); - - expect(window.history.pushState).toHaveBeenCalledWith( - {}, - '', - `${TEST_LOCATION}?state=opened&order_by=priority&sort=asc&page=${newPage}`, - ); - }); - }); - - describe('when total-items is not defined in the headers', () => { - const page = 2; - const prevPage = page - 1; - const nextPage = page + 1; - - beforeEach(() => { - setupApiMock(() => [ - 200, - MOCK_ISSUES.slice(0, PAGE_SIZE), - { - 'x-page': page, - }, - ]); - - factory(); - - return waitForPromises(); - }); - - it('finds the correct props applied to GlPagination', () => { - expect(wrapper.find(GlPagination).props()).toMatchObject({ - nextPage, - prevPage, - value: page, - }); - }); - }); - }); - - describe('when type is "jira"', () => { - it('renders FilteredSearchBar', () => { - factory({ type: 'jira' }); - - expect(findFilteredSearchBar().exists()).toBe(true); - }); - - describe('initialSortBy', () => { - const query = '?sort=updated_asc'; - - it('sets default value', () => { - factory({ type: 'jira' }); - - expect(findFilteredSearchBar().props('initialSortBy')).toBe('created_desc'); - }); - - it('sets value according to query', () => { - setWindowLocation(query); - - factory({ type: 'jira' }); - - expect(findFilteredSearchBar().props('initialSortBy')).toBe('updated_asc'); - }); - }); - - describe('initialFilterValue', () => { - it('does not set value when no query', () => { - factory({ type: 'jira' }); - - expect(findFilteredSearchBar().props('initialFilterValue')).toEqual([]); - }); - - it('sets value according to query', () => { - const query = '?search=free+text'; - - setWindowLocation(query); - - factory({ type: 'jira' }); - - expect(findFilteredSearchBar().props('initialFilterValue')).toEqual(['free text']); - }); - }); - - describe('on filter search', () => { - beforeEach(() => { - factory({ type: 'jira' }); - - window.history.pushState = jest.fn(); - }); - - afterEach(() => { - window.history.pushState.mockRestore(); - }); - - const emitOnFilter = (filter) => findFilteredSearchBar().vm.$emit('onFilter', filter); - - describe('empty filter', () => { - const mockFilter = []; - - it('updates URL with correct params', () => { - emitOnFilter(mockFilter); - - expect(window.history.pushState).toHaveBeenCalledWith( - {}, - '', - `${TEST_LOCATION}?state=opened`, - ); - }); - }); - - describe('filter with search term', () => { - const mockFilter = [ - { - type: 'filtered-search-term', - value: { data: 'free' }, - }, - ]; - - it('updates URL with correct params', () => { - emitOnFilter(mockFilter); - - expect(window.history.pushState).toHaveBeenCalledWith( - {}, - '', - `${TEST_LOCATION}?state=opened&search=free`, - ); - }); - }); - - describe('filter with multiple search terms', () => { - const mockFilter = [ - { - type: 'filtered-search-term', - value: { data: 'free' }, - }, - { - type: 'filtered-search-term', - value: { data: 'text' }, - }, - ]; - - it('updates URL with correct params', () => { - emitOnFilter(mockFilter); - - expect(window.history.pushState).toHaveBeenCalledWith( - {}, - '', - `${TEST_LOCATION}?state=opened&search=free+text`, - ); - }); - }); - }); - }); -}); diff --git a/spec/frontend/issues_list/issuable_list_test_data.js b/spec/frontend/issues_list/issuable_list_test_data.js deleted file mode 100644 index 313aa15bd31..00000000000 --- a/spec/frontend/issues_list/issuable_list_test_data.js +++ /dev/null @@ -1,77 +0,0 @@ -export const simpleIssue = { - id: 442, - iid: 31, - title: 'Dismiss Cipher with no integrity', - state: 'opened', - created_at: '2019-08-26T19:06:32.667Z', - updated_at: '2019-08-28T19:53:58.314Z', - labels: [], - milestone: null, - assignees: [], - author: { - id: 3, - name: 'Elnora Bernhard', - username: 'treva.lesch', - state: 'active', - avatar_url: 'https://www.gravatar.com/avatar/a8c0d9c2882406cf2a9b71494625a796?s=80&d=identicon', - web_url: 'http://localhost:3001/treva.lesch', - }, - assignee: null, - user_notes_count: 0, - blocking_issues_count: 0, - merge_requests_count: 0, - upvotes: 0, - downvotes: 0, - due_date: null, - confidential: false, - web_url: 'http://localhost:3001/h5bp/html5-boilerplate/issues/31', - has_tasks: false, - weight: null, - references: { - relative: 'html-boilerplate#45', - }, - health_status: 'on_track', -}; - -export const testLabels = [ - { - id: 1, - name: 'Tanuki', - description: 'A cute animal', - color: '#ff0000', - text_color: '#ffffff', - }, - { - id: 2, - name: 'Octocat', - description: 'A grotesque mish-mash of whiskers and tentacles', - color: '#333333', - text_color: '#000000', - }, - { - id: 3, - name: 'scoped::label', - description: 'A scoped label', - color: '#00ff00', - text_color: '#ffffff', - }, -]; - -export const testAssignees = [ - { - id: 1, - name: 'Administrator', - username: 'root', - state: 'active', - avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - web_url: 'http://localhost:3001/root', - }, - { - id: 22, - name: 'User 0', - username: 'user0', - state: 'active', - avatar_url: 'https://www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80&d=identicon', - web_url: 'http://localhost:3001/user0', - }, -]; diff --git a/spec/frontend/issues_list/service_desk_helper_spec.js b/spec/frontend/issues_list/service_desk_helper_spec.js deleted file mode 100644 index 16aee853341..00000000000 --- a/spec/frontend/issues_list/service_desk_helper_spec.js +++ /dev/null @@ -1,28 +0,0 @@ -import { emptyStateHelper, generateMessages } from '~/issues_list/service_desk_helper'; - -describe('service desk helper', () => { - const emptyStateMessages = generateMessages({}); - - // Note: isServiceDeskEnabled must not be true when isServiceDeskSupported is false (it's an invalid case). - describe.each` - isServiceDeskSupported | isServiceDeskEnabled | canEditProjectSettings | expectedMessage - ${true} | ${true} | ${true} | ${'serviceDeskEnabledAndCanEditProjectSettings'} - ${true} | ${true} | ${false} | ${'serviceDeskEnabledAndCannotEditProjectSettings'} - ${true} | ${false} | ${true} | ${'serviceDeskDisabledAndCanEditProjectSettings'} - ${true} | ${false} | ${false} | ${'serviceDeskDisabledAndCannotEditProjectSettings'} - ${false} | ${false} | ${true} | ${'serviceDeskIsNotSupported'} - ${false} | ${false} | ${false} | ${'serviceDeskIsNotEnabled'} - `( - 'isServiceDeskSupported = $isServiceDeskSupported, isServiceDeskEnabled = $isServiceDeskEnabled, canEditProjectSettings = $canEditProjectSettings', - ({ isServiceDeskSupported, isServiceDeskEnabled, canEditProjectSettings, expectedMessage }) => { - it(`displays ${expectedMessage} message`, () => { - const emptyStateMeta = { - isServiceDeskEnabled, - isServiceDeskSupported, - canEditProjectSettings, - }; - expect(emptyStateHelper(emptyStateMeta)).toEqual(emptyStateMessages[expectedMessage]); - }); - }, - ); -}); diff --git a/spec/frontend/jira_import/utils/jira_import_utils_spec.js b/spec/frontend/jira_import/utils/jira_import_utils_spec.js index 9696d95f8c4..4207038f50c 100644 --- a/spec/frontend/jira_import/utils/jira_import_utils_spec.js +++ b/spec/frontend/jira_import/utils/jira_import_utils_spec.js @@ -1,5 +1,5 @@ import { useLocalStorageSpy } from 'helpers/local_storage_helper'; -import { JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY } from '~/issues_list/constants'; +import { JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY } from '~/jira_import/utils/constants'; import { calculateJiraImportLabel, extractJiraProjectsOptions, diff --git a/spec/frontend/jobs/bridge/app_spec.js b/spec/frontend/jobs/bridge/app_spec.js index 0e232ab240d..c0faab90552 100644 --- a/spec/frontend/jobs/bridge/app_spec.js +++ b/spec/frontend/jobs/bridge/app_spec.js @@ -1,27 +1,104 @@ -import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; +import { GlLoadingIcon } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import getPipelineQuery from '~/jobs/bridge/graphql/queries/pipeline.query.graphql'; +import waitForPromises from 'helpers/wait_for_promises'; import BridgeApp from '~/jobs/bridge/app.vue'; import BridgeEmptyState from '~/jobs/bridge/components/empty_state.vue'; import BridgeSidebar from '~/jobs/bridge/components/sidebar.vue'; +import CiHeader from '~/vue_shared/components/header_ci_component.vue'; +import { + MOCK_BUILD_ID, + MOCK_PIPELINE_IID, + MOCK_PROJECT_FULL_PATH, + mockPipelineQueryResponse, +} from './mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); describe('Bridge Show Page', () => { let wrapper; + let mockApollo; + let mockPipelineQuery; + + const createComponent = (options) => { + wrapper = shallowMount(BridgeApp, { + provide: { + buildId: MOCK_BUILD_ID, + projectFullPath: MOCK_PROJECT_FULL_PATH, + pipelineIid: MOCK_PIPELINE_IID, + }, + mocks: { + $apollo: { + queries: { + pipeline: { + loading: true, + }, + }, + }, + }, + ...options, + }); + }; - const createComponent = () => { - wrapper = shallowMount(BridgeApp, {}); + const createComponentWithApollo = () => { + const handlers = [[getPipelineQuery, mockPipelineQuery]]; + mockApollo = createMockApollo(handlers); + + createComponent({ + localVue, + apolloProvider: mockApollo, + mocks: {}, + }); }; + const findCiHeader = () => wrapper.findComponent(CiHeader); const findEmptyState = () => wrapper.findComponent(BridgeEmptyState); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findSidebar = () => wrapper.findComponent(BridgeSidebar); + beforeEach(() => { + mockPipelineQuery = jest.fn(); + }); + afterEach(() => { + mockPipelineQuery.mockReset(); wrapper.destroy(); }); - describe('template', () => { + describe('while pipeline query is loading', () => { beforeEach(() => { createComponent(); }); + it('renders loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('after pipeline query is loaded', () => { + beforeEach(() => { + mockPipelineQuery.mockResolvedValue(mockPipelineQueryResponse); + createComponentWithApollo(); + waitForPromises(); + }); + + it('query is called with correct variables', async () => { + expect(mockPipelineQuery).toHaveBeenCalledTimes(1); + expect(mockPipelineQuery).toHaveBeenCalledWith({ + fullPath: MOCK_PROJECT_FULL_PATH, + iid: MOCK_PIPELINE_IID, + }); + }); + + it('renders CI header state', () => { + expect(findCiHeader().exists()).toBe(true); + }); + it('renders empty state', () => { expect(findEmptyState().exists()).toBe(true); }); @@ -30,4 +107,42 @@ describe('Bridge Show Page', () => { expect(findSidebar().exists()).toBe(true); }); }); + + describe('sidebar expansion', () => { + beforeEach(() => { + mockPipelineQuery.mockResolvedValue(mockPipelineQueryResponse); + createComponentWithApollo(); + waitForPromises(); + }); + + describe('on resize', () => { + it.each` + breakpoint | isSidebarExpanded + ${'xs'} | ${false} + ${'sm'} | ${false} + ${'md'} | ${true} + ${'lg'} | ${true} + ${'xl'} | ${true} + `( + 'sets isSidebarExpanded to `$isSidebarExpanded` when the breakpoint is "$breakpoint"', + async ({ breakpoint, isSidebarExpanded }) => { + jest.spyOn(GlBreakpointInstance, 'getBreakpointSize').mockReturnValue(breakpoint); + + window.dispatchEvent(new Event('resize')); + await nextTick(); + + expect(findSidebar().exists()).toBe(isSidebarExpanded); + }, + ); + }); + + it('toggles expansion on button click', async () => { + expect(findSidebar().exists()).toBe(true); + + wrapper.vm.toggleSidebar(); + await nextTick(); + + expect(findSidebar().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/jobs/bridge/components/empty_state_spec.js b/spec/frontend/jobs/bridge/components/empty_state_spec.js index 83642450118..38c55b296f0 100644 --- a/spec/frontend/jobs/bridge/components/empty_state_spec.js +++ b/spec/frontend/jobs/bridge/components/empty_state_spec.js @@ -6,14 +6,13 @@ import { MOCK_EMPTY_ILLUSTRATION_PATH, MOCK_PATH_TO_DOWNSTREAM } from '../mock_d describe('Bridge Empty State', () => { let wrapper; - const createComponent = (props) => { + const createComponent = ({ downstreamPipelinePath }) => { wrapper = shallowMount(BridgeEmptyState, { provide: { emptyStateIllustrationPath: MOCK_EMPTY_ILLUSTRATION_PATH, }, propsData: { - downstreamPipelinePath: MOCK_PATH_TO_DOWNSTREAM, - ...props, + downstreamPipelinePath, }, }); }; @@ -28,7 +27,7 @@ describe('Bridge Empty State', () => { describe('template', () => { beforeEach(() => { - createComponent(); + createComponent({ downstreamPipelinePath: MOCK_PATH_TO_DOWNSTREAM }); }); it('renders illustration', () => { diff --git a/spec/frontend/jobs/bridge/components/sidebar_spec.js b/spec/frontend/jobs/bridge/components/sidebar_spec.js index ba4018753af..5006d4f08a6 100644 --- a/spec/frontend/jobs/bridge/components/sidebar_spec.js +++ b/spec/frontend/jobs/bridge/components/sidebar_spec.js @@ -1,24 +1,38 @@ import { GlButton, GlDropdown } from '@gitlab/ui'; -import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; -import { nextTick } from 'vue'; import { shallowMount } from '@vue/test-utils'; import BridgeSidebar from '~/jobs/bridge/components/sidebar.vue'; -import { BUILD_NAME } from '../mock_data'; +import CommitBlock from '~/jobs/components/commit_block.vue'; +import { mockCommit, mockJob } from '../mock_data'; describe('Bridge Sidebar', () => { let wrapper; - const createComponent = () => { + const MockHeaderEl = { + getBoundingClientRect() { + return { + bottom: '40', + }; + }, + }; + + const createComponent = ({ featureFlag } = {}) => { wrapper = shallowMount(BridgeSidebar, { provide: { - buildName: BUILD_NAME, + glFeatures: { + triggerJobRetryAction: featureFlag, + }, + }, + propsData: { + bridgeJob: mockJob, + commit: mockCommit, }, }); }; - const findSidebar = () => wrapper.find('aside'); + const findJobTitle = () => wrapper.find('h4'); + const findCommitBlock = () => wrapper.findComponent(CommitBlock); const findRetryDropdown = () => wrapper.find(GlDropdown); - const findToggle = () => wrapper.find(GlButton); + const findToggleBtn = () => wrapper.findComponent(GlButton); afterEach(() => { wrapper.destroy(); @@ -29,8 +43,23 @@ describe('Bridge Sidebar', () => { createComponent(); }); - it('renders retry dropdown', () => { - expect(findRetryDropdown().exists()).toBe(true); + it('renders job name', () => { + expect(findJobTitle().text()).toBe(mockJob.name); + }); + + it('renders commit information', () => { + expect(findCommitBlock().exists()).toBe(true); + }); + }); + + describe('styles', () => { + beforeEach(async () => { + jest.spyOn(document, 'querySelector').mockReturnValue(MockHeaderEl); + createComponent(); + }); + + it('calculates root styles correctly', () => { + expect(wrapper.attributes('style')).toBe('width: 290px; top: 40px;'); }); }); @@ -39,38 +68,32 @@ describe('Bridge Sidebar', () => { createComponent(); }); - it('toggles expansion on button click', async () => { - expect(findSidebar().classes()).not.toContain('gl-display-none'); + it('emits toggle sidebar event on button click', async () => { + expect(wrapper.emitted('toggleSidebar')).toBe(undefined); - findToggle().vm.$emit('click'); - await nextTick(); + findToggleBtn().vm.$emit('click'); - expect(findSidebar().classes()).toContain('gl-display-none'); + expect(wrapper.emitted('toggleSidebar')).toHaveLength(1); }); + }); - describe('on resize', () => { - it.each` - breakpoint | isSidebarExpanded - ${'xs'} | ${false} - ${'sm'} | ${false} - ${'md'} | ${true} - ${'lg'} | ${true} - ${'xl'} | ${true} - `( - 'sets isSidebarExpanded to `$isSidebarExpanded` when the breakpoint is "$breakpoint"', - async ({ breakpoint, isSidebarExpanded }) => { - jest.spyOn(GlBreakpointInstance, 'getBreakpointSize').mockReturnValue(breakpoint); - - window.dispatchEvent(new Event('resize')); - await nextTick(); - - if (isSidebarExpanded) { - expect(findSidebar().classes()).not.toContain('gl-display-none'); - } else { - expect(findSidebar().classes()).toContain('gl-display-none'); - } - }, - ); + describe('retry action', () => { + describe('when feature flag is ON', () => { + beforeEach(() => { + createComponent({ featureFlag: true }); + }); + + it('renders retry dropdown', () => { + expect(findRetryDropdown().exists()).toBe(true); + }); + }); + + describe('when feature flag is OFF', () => { + it('does not render retry dropdown', () => { + createComponent({ featureFlag: false }); + + expect(findRetryDropdown().exists()).toBe(false); + }); }); }); }); diff --git a/spec/frontend/jobs/bridge/mock_data.js b/spec/frontend/jobs/bridge/mock_data.js index 146d1a062ac..4084bb54163 100644 --- a/spec/frontend/jobs/bridge/mock_data.js +++ b/spec/frontend/jobs/bridge/mock_data.js @@ -1,3 +1,102 @@ export const MOCK_EMPTY_ILLUSTRATION_PATH = '/path/to/svg'; export const MOCK_PATH_TO_DOWNSTREAM = '/path/to/downstream/pipeline'; -export const BUILD_NAME = 'Child Pipeline Trigger'; +export const MOCK_BUILD_ID = '1331'; +export const MOCK_PIPELINE_IID = '174'; +export const MOCK_PROJECT_FULL_PATH = '/root/project/'; +export const MOCK_SHA = '38f3d89147765427a7ce58be28cd76d14efa682a'; + +export const mockCommit = { + id: `gid://gitlab/CommitPresenter/${MOCK_SHA}`, + shortId: '38f3d891', + title: 'Update .gitlab-ci.yml file', + webPath: `/root/project/-/commit/${MOCK_SHA}`, + __typename: 'Commit', +}; + +export const mockJob = { + createdAt: '2021-12-10T09:05:45Z', + id: 'gid://gitlab/Ci::Build/1331', + name: 'triggerJobName', + scheduledAt: null, + startedAt: '2021-12-10T09:13:43Z', + status: 'SUCCESS', + triggered: null, + detailedStatus: { + id: '1', + detailsPath: '/root/project/-/jobs/1331', + icon: 'status_success', + group: 'success', + text: 'passed', + tooltip: 'passed', + __typename: 'DetailedStatus', + }, + downstreamPipeline: { + id: '1', + path: '/root/project/-/pipelines/175', + }, + stage: { + id: '1', + name: 'build', + __typename: 'CiStage', + }, + __typename: 'CiJob', +}; + +export const mockUser = { + id: 'gid://gitlab/User/1', + avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + name: 'Administrator', + username: 'root', + webPath: '/root', + webUrl: 'http://gdk.test:3000/root', + status: { + message: 'making great things', + __typename: 'UserStatus', + }, + __typename: 'UserCore', +}; + +export const mockStage = { + id: '1', + name: 'build', + jobs: { + nodes: [mockJob], + __typename: 'CiJobConnection', + }, + __typename: 'CiStage', +}; + +export const mockPipelineQueryResponse = { + data: { + project: { + id: '1', + pipeline: { + commit: mockCommit, + id: 'gid://gitlab/Ci::Pipeline/174', + iid: '88', + path: '/root/project/-/pipelines/174', + sha: MOCK_SHA, + ref: 'main', + refPath: 'path/to/ref', + user: mockUser, + detailedStatus: { + id: '1', + icon: 'status_failed', + group: 'failed', + __typename: 'DetailedStatus', + }, + stages: { + edges: [ + { + node: mockStage, + __typename: 'CiStageEdge', + }, + ], + __typename: 'CiStageConnection', + }, + __typename: 'Pipeline', + }, + __typename: 'Project', + }, + }, +}; diff --git a/spec/frontend/jobs/components/table/job_table_app_spec.js b/spec/frontend/jobs/components/table/job_table_app_spec.js index 482d0df4e9a..05988eecb10 100644 --- a/spec/frontend/jobs/components/table/job_table_app_spec.js +++ b/spec/frontend/jobs/components/table/job_table_app_spec.js @@ -114,6 +114,8 @@ describe('Job table app', () => { await wrapper.vm.$nextTick(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ jobs: { pageInfo: { diff --git a/spec/frontend/labels/delete_label_modal_spec.js b/spec/frontend/labels/delete_label_modal_spec.js index c1e6ce87990..98049538948 100644 --- a/spec/frontend/labels/delete_label_modal_spec.js +++ b/spec/frontend/labels/delete_label_modal_spec.js @@ -13,6 +13,10 @@ describe('DeleteLabelModal', () => { subjectName: 'GitLab Org', destroyPath: `${TEST_HOST}/2`, }, + { + labelName: 'admin label', + destroyPath: `${TEST_HOST}/3`, + }, ]; beforeEach(() => { @@ -22,8 +26,12 @@ describe('DeleteLabelModal', () => { 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); + + if (x.subjectName) { + button.setAttribute('data-subject-name', x.subjectName); + } + button.innerHTML = 'Action'; buttonContainer.appendChild(button); }); @@ -62,6 +70,7 @@ describe('DeleteLabelModal', () => { index ${0} ${1} + ${2} `(`when multiple buttons exist`, ({ index }) => { beforeEach(() => { initDeleteLabelModal(); @@ -69,14 +78,22 @@ describe('DeleteLabelModal', () => { }); 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, - ); + const button = buttons[index]; + + expect(findModal().querySelector('.modal-title').innerHTML).toContain(button.labelName); + + if (button.subjectName) { + expect(findModal().querySelector('.modal-body').textContent).toContain( + `${button.labelName} will be permanently deleted from ${button.subjectName}. This cannot be undone.`, + ); + } else { + expect(findModal().querySelector('.modal-body').textContent).toContain( + `${button.labelName} will be permanently deleted. This cannot be undone.`, + ); + } + expect(findModal().querySelector('.modal-footer .btn-danger').href).toContain( - buttons[index].destroyPath, + button.destroyPath, ); }); }); diff --git a/spec/frontend/lib/utils/resize_observer_spec.js b/spec/frontend/lib/utils/resize_observer_spec.js new file mode 100644 index 00000000000..419aff28935 --- /dev/null +++ b/spec/frontend/lib/utils/resize_observer_spec.js @@ -0,0 +1,68 @@ +import { contentTop } from '~/lib/utils/common_utils'; +import { scrollToTargetOnResize } from '~/lib/utils/resize_observer'; + +jest.mock('~/lib/utils/common_utils'); + +function mockStickyHeaderSize(val) { + contentTop.mockReturnValue(val); +} + +describe('ResizeObserver Utility', () => { + let observer; + const triggerResize = () => { + const entry = document.querySelector('#content-body'); + entry.dispatchEvent(new CustomEvent(`ResizeUpdate`, { detail: { entry } })); + }; + + beforeEach(() => { + mockStickyHeaderSize(90); + + jest.spyOn(document.documentElement, 'scrollTo'); + + setFixtures(`<div id="content-body"><div class="target">element to scroll to</div></div>`); + + const target = document.querySelector('.target'); + + jest.spyOn(target, 'getBoundingClientRect').mockReturnValue({ top: 200 }); + + observer = scrollToTargetOnResize({ + target: '.target', + container: '#content-body', + }); + }); + + afterEach(() => { + contentTop.mockReset(); + }); + + describe('Observer behavior', () => { + it('returns null for empty target', () => { + observer = scrollToTargetOnResize({ + target: '', + container: '#content-body', + }); + + expect(observer).toBe(null); + }); + + it('returns ResizeObserver instance', () => { + expect(observer).toBeInstanceOf(ResizeObserver); + }); + + it('scrolls body so anchor is just below sticky header (contentTop)', () => { + triggerResize(); + + expect(document.documentElement.scrollTo).toHaveBeenCalledWith({ top: 110 }); + }); + + const interactionEvents = ['mousedown', 'touchstart', 'keydown', 'wheel']; + it.each(interactionEvents)('does not hijack scroll after user input from %s', (eventType) => { + const event = new Event(eventType); + document.dispatchEvent(event); + + triggerResize(); + + expect(document.documentElement.scrollTo).not.toHaveBeenCalledWith(); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap index aaa0a91ffe0..681fb05a6c4 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -128,7 +128,7 @@ exports[`Dashboard template matches the default snapshot 1`] = ` emptynodatasvgpath="/images/illustrations/monitoring/no_data.svg" emptyunabletoconnectsvgpath="/images/illustrations/monitoring/unable_to_connect.svg" selectedstate="gettingStarted" - settingspath="/monitoring/monitor-project/-/services/prometheus/edit" + settingspath="/monitoring/monitor-project/-/integrations/prometheus/edit" /> </div> `; diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js index 27f7489aa49..ff6f0b9b0c7 100644 --- a/spec/frontend/monitoring/components/charts/time_series_spec.js +++ b/spec/frontend/monitoring/components/charts/time_series_spec.js @@ -661,6 +661,8 @@ describe('Time series component', () => { const commitUrl = `${mockProjectDir}/-/commit/${mockSha}`; beforeEach(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ tooltip: { type: 'deployments', diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js index 9331048bce3..7730e7f347f 100644 --- a/spec/frontend/monitoring/components/dashboard_spec.js +++ b/spec/frontend/monitoring/components/dashboard_spec.js @@ -792,6 +792,8 @@ describe('Dashboard', () => { }); createShallowWrapper({ hasMetrics: true }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ hoveredPanel: panelRef }); return wrapper.vm.$nextTick(); diff --git a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js index 589354e7849..f6d30384847 100644 --- a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js +++ b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js @@ -38,6 +38,8 @@ describe('DashboardsDropdown', () => { const findSearchInput = () => wrapper.find({ ref: 'monitorDashboardsDropdownSearch' }); const findNoItemsMsg = () => wrapper.find({ ref: 'monitorDashboardsDropdownMsg' }); const findStarredListDivider = () => wrapper.find({ ref: 'starredListDivider' }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax const setSearchTerm = (searchTerm) => wrapper.setData({ searchTerm }); beforeEach(() => { diff --git a/spec/frontend/mr_popover/mr_popover_spec.js b/spec/frontend/mr_popover/mr_popover_spec.js index 0c6e4211b10..36ad82e93a5 100644 --- a/spec/frontend/mr_popover/mr_popover_spec.js +++ b/spec/frontend/mr_popover/mr_popover_spec.js @@ -35,6 +35,8 @@ describe('MR Popover', () => { describe('loaded state', () => { it('matches the snapshot', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ mergeRequest: { title: 'Updated Title', @@ -55,6 +57,8 @@ describe('MR Popover', () => { }); it('does not show CI Icon if there is no pipeline data', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ mergeRequest: { state: 'opened', diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index c3a51c51de0..16dbf60cef4 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -263,6 +263,8 @@ describe('issue_comment_form component', () => { jest.spyOn(wrapper.vm, 'stopPolling'); jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ note: 'hello world' }); await findCommentButton().trigger('click'); @@ -388,6 +390,8 @@ describe('issue_comment_form component', () => { it('should enable comment button if it has note', async () => { mountComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ note: 'Foo' }); expect(findCommentTypeDropdown().props('disabled')).toBe(false); diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js index 48bfd6eac5a..d3b5ab02f24 100644 --- a/spec/frontend/notes/components/note_form_spec.js +++ b/spec/frontend/notes/components/note_form_spec.js @@ -257,6 +257,8 @@ describe('issue_note_form component', () => { props = { ...props, ...options }; wrapper = createComponentWrapper(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isSubmittingWithKeydown: true }); const textarea = wrapper.find('textarea'); diff --git a/spec/frontend/notifications/components/custom_notifications_modal_spec.js b/spec/frontend/notifications/components/custom_notifications_modal_spec.js index 0782ec7cdd5..7a036d25559 100644 --- a/spec/frontend/notifications/components/custom_notifications_modal_spec.js +++ b/spec/frontend/notifications/components/custom_notifications_modal_spec.js @@ -88,6 +88,8 @@ describe('CustomNotificationsModal', () => { beforeEach(async () => { wrapper = createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ events: [ { id: 'new_release', enabled: true, name: 'New release', loading: false }, @@ -211,6 +213,8 @@ describe('CustomNotificationsModal', () => { wrapper = createComponent({ injectedProperties }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ events: [ { id: 'new_release', enabled: true, name: 'New release', loading: false }, @@ -239,6 +243,8 @@ describe('CustomNotificationsModal', () => { mockAxios.onPut('/api/v4/notification_settings').reply(httpStatus.NOT_FOUND, {}); wrapper = createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ events: [ { id: 'new_release', enabled: true, name: 'New release', loading: false }, diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js index f06300efa29..5278e730ec9 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js @@ -1,7 +1,6 @@ -import { GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { GlDropdownItem, GlIcon, GlDropdown } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; -import { GlDropdown } from 'jest/packages_and_registries/container_registry/explorer/stubs'; import { useFakeDate } from 'helpers/fake_date'; import createMockApollo from 'helpers/mock_apollo_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; @@ -51,6 +50,7 @@ describe('Details Header', () => { const findCleanup = () => findByTestId('cleanup'); const findDeleteButton = () => wrapper.findComponent(GlDropdownItem); const findInfoIcon = () => wrapper.findComponent(GlIcon); + const findMenu = () => wrapper.findComponent(GlDropdown); const waitForMetadataItems = async () => { // Metadata items are printed by a loop in the title-area and it takes two ticks for them to be available @@ -139,51 +139,53 @@ describe('Details Header', () => { }); }); - describe('delete button', () => { - it('exists', () => { - mountComponent(); + describe('menu', () => { + it.each` + canDelete | disabled | isVisible + ${true} | ${false} | ${true} + ${true} | ${true} | ${false} + ${false} | ${false} | ${false} + ${false} | ${true} | ${false} + `( + 'when canDelete is $canDelete and disabled is $disabled is $isVisible that the menu is visible', + ({ canDelete, disabled, isVisible }) => { + mountComponent({ propsData: { image: { ...defaultImage, canDelete }, disabled } }); - expect(findDeleteButton().exists()).toBe(true); - }); + expect(findMenu().exists()).toBe(isVisible); + }, + ); - it('has the correct text', () => { - mountComponent(); + describe('delete button', () => { + it('exists', () => { + mountComponent(); - expect(findDeleteButton().text()).toBe('Delete image repository'); - }); + expect(findDeleteButton().exists()).toBe(true); + }); - it('has the correct props', () => { - mountComponent(); + it('has the correct text', () => { + mountComponent(); - expect(findDeleteButton().attributes()).toMatchObject( - expect.objectContaining({ - variant: 'danger', - }), - ); - }); + expect(findDeleteButton().text()).toBe('Delete image repository'); + }); - it('emits the correct event', () => { - mountComponent(); + it('has the correct props', () => { + mountComponent(); - findDeleteButton().vm.$emit('click'); + expect(findDeleteButton().attributes()).toMatchObject( + expect.objectContaining({ + variant: 'danger', + }), + ); + }); - expect(wrapper.emitted('delete')).toEqual([[]]); - }); + it('emits the correct event', () => { + mountComponent(); - it.each` - canDelete | disabled | isDisabled - ${true} | ${false} | ${undefined} - ${true} | ${true} | ${'true'} - ${false} | ${false} | ${'true'} - ${false} | ${true} | ${'true'} - `( - 'when canDelete is $canDelete and disabled is $disabled is $isDisabled that the button is disabled', - ({ canDelete, disabled, isDisabled }) => { - mountComponent({ propsData: { image: { ...defaultImage, canDelete }, disabled } }); + findDeleteButton().vm.$emit('click'); - expect(findDeleteButton().attributes('disabled')).toBe(isDisabled); - }, - ); + expect(wrapper.emitted('delete')).toEqual([[]]); + }); + }); }); describe('metadata items', () => { diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/empty_state_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/empty_state_spec.js deleted file mode 100644 index f14284e9efe..00000000000 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/empty_state_spec.js +++ /dev/null @@ -1,54 +0,0 @@ -import { GlEmptyState } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import component from '~/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue'; -import { - NO_TAGS_TITLE, - NO_TAGS_MESSAGE, - MISSING_OR_DELETED_IMAGE_TITLE, - MISSING_OR_DELETED_IMAGE_MESSAGE, -} from '~/packages_and_registries/container_registry/explorer/constants'; - -describe('EmptyTagsState component', () => { - let wrapper; - - const findEmptyState = () => wrapper.find(GlEmptyState); - - const mountComponent = (propsData) => { - wrapper = shallowMount(component, { - stubs: { - GlEmptyState, - }, - propsData, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('contains gl-empty-state', () => { - mountComponent(); - expect(findEmptyState().exists()).toBe(true); - }); - - it.each` - isEmptyImage | title | description - ${false} | ${NO_TAGS_TITLE} | ${NO_TAGS_MESSAGE} - ${true} | ${MISSING_OR_DELETED_IMAGE_TITLE} | ${MISSING_OR_DELETED_IMAGE_MESSAGE} - `( - 'when isEmptyImage is $isEmptyImage has the correct props', - ({ isEmptyImage, title, description }) => { - mountComponent({ - noContainersImage: 'foo', - isEmptyImage, - }); - - expect(findEmptyState().props()).toMatchObject({ - title, - description, - svgPath: 'foo', - }); - }, - ); -}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js index 00b1d03b7c2..057312828ff 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js @@ -75,16 +75,19 @@ describe('tags list row', () => { }); it.each` - digest | disabled - ${'foo'} | ${true} - ${null} | ${false} - ${null} | ${true} - ${'foo'} | ${true} - `('is disabled when the digest $digest and disabled is $disabled', ({ digest, disabled }) => { - mountComponent({ tag: { ...tag, digest }, disabled }); + digest | disabled | isDisabled + ${'foo'} | ${true} | ${'true'} + ${null} | ${true} | ${'true'} + ${null} | ${false} | ${undefined} + ${'foo'} | ${false} | ${undefined} + `( + 'disabled attribute is set to $isDisabled when the digest $digest and disabled is $disabled', + ({ digest, disabled, isDisabled }) => { + mountComponent({ tag: { ...tag, digest }, disabled }); - expect(findCheckbox().attributes('disabled')).toBe('true'); - }); + expect(findCheckbox().attributes('disabled')).toBe(isDisabled); + }, + ); it('is wired to the selected prop', () => { mountComponent({ ...defaultProps, selected: true }); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js index 56f12e2f0bb..0dcf988c814 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js @@ -1,16 +1,25 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import { nextTick } from 'vue'; +import { GlEmptyState } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { stripTypenames } from 'helpers/graphql_helpers'; -import EmptyTagsState from '~/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue'; + import component from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue'; import TagsListRow from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue'; import TagsLoader from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue'; import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; import getContainerRepositoryTagsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql'; -import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/container_registry/explorer/constants/index'; +import { + GRAPHQL_PAGE_SIZE, + NO_TAGS_TITLE, + NO_TAGS_MESSAGE, + NO_TAGS_MATCHING_FILTERS_TITLE, + NO_TAGS_MATCHING_FILTERS_DESCRIPTION, +} from '~/packages_and_registries/container_registry/explorer/constants/index'; +import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import { tagsMock, imageTagsMock, tagsPageInfo } from '../../mock_data'; const localVue = createLocalVue(); @@ -21,11 +30,20 @@ describe('Tags List', () => { let resolver; const tags = [...tagsMock]; + const defaultConfig = { + noContainersImage: 'noContainersImage', + }; + + const findPersistedSearch = () => wrapper.findComponent(PersistedSearch); const findTagsListRow = () => wrapper.findAllComponents(TagsListRow); const findRegistryList = () => wrapper.findComponent(RegistryList); - const findEmptyState = () => wrapper.findComponent(EmptyTagsState); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findTagsLoader = () => wrapper.findComponent(TagsLoader); + const fireFirstSortUpdate = () => { + findPersistedSearch().vm.$emit('update', { sort: 'NAME_ASC', filters: [] }); + }; + const waitForApolloRequestRender = async () => { await waitForPromises(); await nextTick(); @@ -44,7 +62,7 @@ describe('Tags List', () => { stubs: { RegistryList }, provide() { return { - config: {}, + config: defaultConfig, }; }, }); @@ -61,10 +79,23 @@ describe('Tags List', () => { describe('registry list', () => { beforeEach(() => { mountComponent(); - + fireFirstSortUpdate(); return waitForApolloRequestRender(); }); + it('has a persisted search', () => { + expect(findPersistedSearch().props()).toMatchObject({ + defaultOrder: 'NAME', + defaultSort: 'asc', + sortableFields: [ + { + label: 'Name', + orderBy: 'NAME', + }, + ], + }); + }); + it('binds the correct props', () => { expect(findRegistryList().props()).toMatchObject({ title: '2 tags', @@ -75,11 +106,13 @@ describe('Tags List', () => { }); describe('events', () => { - it('prev-page fetch the previous page', () => { + it('prev-page fetch the previous page', async () => { findRegistryList().vm.$emit('prev-page'); expect(resolver).toHaveBeenCalledWith({ first: null, + name: '', + sort: 'NAME_ASC', before: tagsPageInfo.startCursor, last: GRAPHQL_PAGE_SIZE, id: '1', @@ -92,6 +125,8 @@ describe('Tags List', () => { expect(resolver).toHaveBeenCalledWith({ after: tagsPageInfo.endCursor, first: GRAPHQL_PAGE_SIZE, + name: '', + sort: 'NAME_ASC', id: '1', }); }); @@ -108,6 +143,7 @@ describe('Tags List', () => { describe('list rows', () => { it('one row exist for each tag', async () => { mountComponent(); + fireFirstSortUpdate(); await waitForApolloRequestRender(); @@ -116,6 +152,7 @@ describe('Tags List', () => { it('the correct props are bound to it', async () => { mountComponent({ propsData: { disabled: true, id: 1 } }); + fireFirstSortUpdate(); await waitForApolloRequestRender(); @@ -130,7 +167,7 @@ describe('Tags List', () => { describe('events', () => { it('select event update the selected items', async () => { mountComponent(); - + fireFirstSortUpdate(); await waitForApolloRequestRender(); findTagsListRow().at(0).vm.$emit('select'); @@ -142,7 +179,7 @@ describe('Tags List', () => { it('delete event emit a delete event', async () => { mountComponent(); - + fireFirstSortUpdate(); await waitForApolloRequestRender(); findTagsListRow().at(0).vm.$emit('delete'); @@ -154,32 +191,45 @@ describe('Tags List', () => { describe('when the list of tags is empty', () => { beforeEach(() => { resolver = jest.fn().mockResolvedValue(imageTagsMock([])); - }); - - it('has the empty state', async () => { mountComponent(); - - await waitForApolloRequestRender(); - - expect(findEmptyState().exists()).toBe(true); + fireFirstSortUpdate(); + return waitForApolloRequestRender(); }); - it('does not show the loader', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - + it('does not show the loader', () => { expect(findTagsLoader().exists()).toBe(false); }); - it('does not show the list', async () => { - mountComponent(); + it('does not show the list', () => { + expect(findRegistryList().exists()).toBe(false); + }); - await waitForApolloRequestRender(); + describe('empty state', () => { + it('default empty state', () => { + expect(findEmptyState().props()).toMatchObject({ + svgPath: defaultConfig.noContainersImage, + title: NO_TAGS_TITLE, + description: NO_TAGS_MESSAGE, + }); + }); - expect(findRegistryList().exists()).toBe(false); + it('when filtered shows a filtered message', async () => { + findPersistedSearch().vm.$emit('update', { + sort: 'NAME_ASC', + filters: [{ type: FILTERED_SEARCH_TERM, value: { data: 'foo' } }], + }); + + await waitForApolloRequestRender(); + + expect(findEmptyState().props()).toMatchObject({ + svgPath: defaultConfig.noContainersImage, + title: NO_TAGS_MATCHING_FILTERS_TITLE, + description: NO_TAGS_MATCHING_FILTERS_DESCRIPTION, + }); + }); }); }); + describe('loading state', () => { it.each` isImageLoading | queryExecuting | loadingVisible @@ -191,7 +241,7 @@ describe('Tags List', () => { 'when the isImageLoading is $isImageLoading, and is $queryExecuting that the query is still executing is $loadingVisible that the loader is shown', async ({ isImageLoading, queryExecuting, loadingVisible }) => { mountComponent({ propsData: { isImageLoading, isMobile: false, id: 1 } }); - + fireFirstSortUpdate(); if (!queryExecuting) { await waitForApolloRequestRender(); } diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js index 9b821ba8ef3..7992bead60a 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js @@ -1,4 +1,4 @@ -import { GlKeysetPagination } from '@gitlab/ui'; +import { GlKeysetPagination, GlEmptyState } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import { nextTick } from 'vue'; @@ -8,7 +8,6 @@ import axios from '~/lib/utils/axios_utils'; import DeleteImage from '~/packages_and_registries/container_registry/explorer/components/delete_image.vue'; import DeleteAlert from '~/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue'; import DetailsHeader from '~/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue'; -import EmptyTagsState from '~/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue'; import PartialCleanupAlert from '~/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue'; import StatusAlert from '~/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue'; import TagsList from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue'; @@ -20,6 +19,8 @@ import { ALERT_DANGER_IMAGE, MISSING_OR_DELETED_IMAGE_BREADCRUMB, ROOT_IMAGE_TEXT, + MISSING_OR_DELETED_IMAGE_TITLE, + MISSING_OR_DELETED_IMAGE_MESSAGE, } from '~/packages_and_registries/container_registry/explorer/constants'; import deleteContainerRepositoryTagsMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql'; import getContainerRepositoryDetailsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql'; @@ -50,7 +51,7 @@ describe('Details Page', () => { const findTagsList = () => wrapper.find(TagsList); const findDeleteAlert = () => wrapper.find(DeleteAlert); const findDetailsHeader = () => wrapper.find(DetailsHeader); - const findEmptyState = () => wrapper.find(EmptyTagsState); + const findEmptyState = () => wrapper.find(GlEmptyState); const findPartialCleanupAlert = () => wrapper.find(PartialCleanupAlert); const findStatusAlert = () => wrapper.find(StatusAlert); const findDeleteImage = () => wrapper.find(DeleteImage); @@ -61,6 +62,10 @@ describe('Details Page', () => { updateName: jest.fn(), }; + const defaultConfig = { + noContainersImage: 'noContainersImage', + }; + const cleanTags = tagsMock.map((t) => { const result = { ...t }; // eslint-disable-next-line no-underscore-dangle @@ -78,7 +83,7 @@ describe('Details Page', () => { mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock), tagsResolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock(imageTagsMock)), options, - config = {}, + config = defaultConfig, } = {}) => { localVue.use(VueApollo); @@ -154,7 +159,11 @@ describe('Details Page', () => { await waitForApolloRequestRender(); - expect(findEmptyState().exists()).toBe(true); + expect(findEmptyState().props()).toMatchObject({ + description: MISSING_OR_DELETED_IMAGE_MESSAGE, + svgPath: defaultConfig.noContainersImage, + title: MISSING_OR_DELETED_IMAGE_TITLE, + }); }); }); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap index 881d441e116..f95564e3fad 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap @@ -15,11 +15,14 @@ exports[`FileSha renders 1`] = ` foo <gl-button-stub - aria-label="Copy this value" + aria-label="Copy SHA" + aria-live="polite" buttontextclasses="" category="tertiary" + data-clipboard-handle-tooltip="false" data-clipboard-text="foo" icon="copy-to-clipboard" + id="clipboard-button-1" size="small" title="Copy SHA" variant="default" diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/file_sha_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/file_sha_spec.js index 9ce590bfb51..d7caa8ca2d8 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/file_sha_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/file_sha_spec.js @@ -4,6 +4,8 @@ import FileSha from '~/packages_and_registries/infrastructure_registry/details/c import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; +jest.mock('lodash/uniqueId', () => (prefix) => (prefix ? `${prefix}1` : 1)); + describe('FileSha', () => { let wrapper; diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap index 99a7b8e427a..7cdf21dde46 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap @@ -10,10 +10,10 @@ exports[`packages_list_app renders 1`] = ` <div> <section - class="row empty-state text-center" + class="gl-display-flex empty-state gl-text-center gl-flex-direction-column" > <div - class="col-12" + class="gl-max-w-full" > <div class="svg-250 svg-content" @@ -28,10 +28,10 @@ exports[`packages_list_app renders 1`] = ` </div> <div - class="col-12" + class="gl-max-w-full gl-m-auto" > <div - class="text-content gl-mx-auto gl-my-0 gl-p-5" + class="gl-mx-auto gl-my-0 gl-p-5" > <h1 class="gl-font-size-h-display gl-line-height-36 h4" diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_spec.js index 2fb76b98925..26569f20e94 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_spec.js @@ -134,6 +134,8 @@ describe('packages_list', () => { }); it('deleteItemConfirmation resets itemToBeDeleted', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ itemToBeDeleted: 1 }); wrapper.vm.deleteItemConfirmation(); expect(wrapper.vm.itemToBeDeleted).toEqual(null); @@ -141,6 +143,8 @@ describe('packages_list', () => { it('deleteItemConfirmation emit package:delete', () => { const itemToBeDeleted = { id: 2 }; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ itemToBeDeleted }); wrapper.vm.deleteItemConfirmation(); return wrapper.vm.$nextTick(() => { @@ -149,6 +153,8 @@ describe('packages_list', () => { }); it('deleteItemCanceled resets itemToBeDeleted', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ itemToBeDeleted: 1 }); wrapper.vm.deleteItemCanceled(); expect(wrapper.vm.itemToBeDeleted).toEqual(null); @@ -194,6 +200,8 @@ describe('packages_list', () => { beforeEach(() => { mountComponent(); eventSpy = jest.spyOn(Tracking, 'event'); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ itemToBeDeleted: { package_type: 'conan' } }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/conan_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/conan_installation_spec.js.snap index e9f80d5f512..b3d0d88be4d 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/conan_installation_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/conan_installation_spec.js.snap @@ -23,14 +23,18 @@ exports[`ConanInstallation renders all the messages 1`] = ` <code-instruction-stub copytext="Copy Conan Setup Command" - instruction="conan remote add gitlab conanPath" + instruction="conan remote add gitlab http://gdk.test:3000/api/v4/projects/1/packages/conan" label="Add Conan Remote" trackingaction="copy_conan_setup_command" trackinglabel="code_instruction" /> - - <gl-sprintf-stub - message="For more information on the Conan registry, %{linkStart}see the documentation%{linkEnd}." - /> + For more information on the Conan registry, + <gl-link-stub + href="/help/user/packages/conan_repository/index" + target="_blank" + > + see the documentation + </gl-link-stub> + . </div> `; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap index 881d441e116..f95564e3fad 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap @@ -15,11 +15,14 @@ exports[`FileSha renders 1`] = ` foo <gl-button-stub - aria-label="Copy this value" + aria-label="Copy SHA" + aria-live="polite" buttontextclasses="" category="tertiary" + data-clipboard-handle-tooltip="false" data-clipboard-text="foo" icon="copy-to-clipboard" + id="clipboard-button-1" size="small" title="Copy SHA" variant="default" diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/maven_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/maven_installation_spec.js.snap index 4865b8205ab..67f1906f6fd 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/maven_installation_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/maven_installation_spec.js.snap @@ -19,7 +19,7 @@ exports[`MavenInstallation groovy renders all the messages 1`] = ` <code-instruction-stub copytext="Copy add Gradle Groovy DSL repository command" instruction="maven { - url 'mavenPath' + url 'http://gdk.test:3000/api/v4/projects/1/packages/maven' }" label="Add Gradle Groovy DSL repository command" multiline="true" @@ -47,7 +47,7 @@ exports[`MavenInstallation kotlin renders all the messages 1`] = ` <code-instruction-stub copytext="Copy add Gradle Kotlin DSL repository command" - instruction="maven(\\"mavenPath\\")" + instruction="maven(\\"http://gdk.test:3000/api/v4/projects/1/packages/maven\\")" label="Add Gradle Kotlin DSL repository command" multiline="true" trackingaction="copy_kotlin_add_to_source_command" @@ -64,9 +64,15 @@ exports[`MavenInstallation maven renders all the messages 1`] = ` /> <p> - <gl-sprintf-stub - message="Copy and paste this inside your %{codeStart}pom.xml%{codeEnd} %{codeStart}dependencies%{codeEnd} block." - /> + Copy and paste this inside your + <code> + pom.xml + </code> + + <code> + dependencies + </code> + block. </p> <code-instruction-stub @@ -97,9 +103,11 @@ exports[`MavenInstallation maven renders all the messages 1`] = ` </h3> <p> - <gl-sprintf-stub - message="If you haven't already done so, you will need to add the below to your %{codeStart}pom.xml%{codeEnd} file." - /> + If you haven't already done so, you will need to add the below to your + <code> + pom.xml + </code> + file. </p> <code-instruction-stub @@ -107,19 +115,19 @@ exports[`MavenInstallation maven renders all the messages 1`] = ` instruction="<repositories> <repository> <id>gitlab-maven</id> - <url>mavenPath</url> + <url>http://gdk.test:3000/api/v4/projects/1/packages/maven</url> </repository> </repositories> <distributionManagement> <repository> <id>gitlab-maven</id> - <url>mavenPath</url> + <url>http://gdk.test:3000/api/v4/projects/1/packages/maven</url> </repository> <snapshotRepository> <id>gitlab-maven</id> - <url>mavenPath</url> + <url>http://gdk.test:3000/api/v4/projects/1/packages/maven</url> </snapshotRepository> </distributionManagement>" label="" @@ -127,9 +135,13 @@ exports[`MavenInstallation maven renders all the messages 1`] = ` trackingaction="copy_maven_setup_xml" trackinglabel="code_instruction" /> - - <gl-sprintf-stub - message="For more information on the Maven registry, %{linkStart}see the documentation%{linkEnd}." - /> + For more information on the Maven registry, + <gl-link-stub + href="/help/user/packages/maven_repository/index" + target="_blank" + > + see the documentation + </gl-link-stub> + . </div> `; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap index d5649e39561..4520ae9c328 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap @@ -32,14 +32,18 @@ exports[`NpmInstallation renders all the messages 1`] = ` <code-instruction-stub copytext="Copy npm setup command" - instruction="echo @gitlab-org:registry=npmPath/ >> .npmrc" + instruction="echo @gitlab-org:registry=npmInstanceUrl/ >> .npmrc" label="" trackingaction="copy_npm_setup_command" trackinglabel="code_instruction" /> - - <gl-sprintf-stub - message="You may also need to setup authentication using an auth token. %{linkStart}See the documentation%{linkEnd} to find out more." - /> + You may also need to setup authentication using an auth token. + <gl-link-stub + href="/help/user/packages/npm_registry/index" + target="_blank" + > + See the documentation + </gl-link-stub> + to find out more. </div> `; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/nuget_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/nuget_installation_spec.js.snap index 29ddd7b77ed..92930a6309a 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/nuget_installation_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/nuget_installation_spec.js.snap @@ -23,14 +23,18 @@ exports[`NugetInstallation renders all the messages 1`] = ` <code-instruction-stub copytext="Copy NuGet Setup Command" - instruction="nuget source Add -Name \\"GitLab\\" -Source \\"nugetPath\\" -UserName <your_username> -Password <your_token>" + instruction="nuget source Add -Name \\"GitLab\\" -Source \\"http://gdk.test:3000/api/v4/projects/1/packages/nuget/index.json\\" -UserName <your_username> -Password <your_token>" label="Add NuGet Source" trackingaction="copy_nuget_setup_command" trackinglabel="code_instruction" /> - - <gl-sprintf-stub - message="For more information on the NuGet registry, %{linkStart}see the documentation%{linkEnd}." - /> + For more information on the NuGet registry, + <gl-link-stub + href="/help/user/packages/nuget_repository/index" + target="_blank" + > + see the documentation + </gl-link-stub> + . </div> `; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap index 158bbbc3463..06ae8645101 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap @@ -10,7 +10,7 @@ exports[`PypiInstallation renders all the messages 1`] = ` <code-instruction-stub copytext="Copy Pip command" data-testid="pip-command" - instruction="pip install @gitlab-org/package-15 --extra-index-url pypiPath" + instruction="pip install @gitlab-org/package-15 --extra-index-url http://__token__:<your_personal_token>@gdk.test:3000/api/v4/projects/1/packages/pypi/simple" label="Pip Command" trackingaction="copy_pip_install_command" trackinglabel="code_instruction" @@ -23,16 +23,18 @@ exports[`PypiInstallation renders all the messages 1`] = ` </h3> <p> - <gl-sprintf-stub - message="If you haven't already done so, you will need to add the below to your %{codeStart}.pypirc%{codeEnd} file." - /> + If you haven't already done so, you will need to add the below to your + <code> + .pypirc + </code> + file. </p> <code-instruction-stub copytext="Copy .pypirc content" data-testid="pypi-setup-content" instruction="[gitlab] -repository = pypiSetupPath +repository = http://gdk.test:3000/api/v4/projects/1/packages/pypi username = __token__ password = <your personal access token>" label="" @@ -40,9 +42,13 @@ password = <your personal access token>" trackingaction="copy_pypi_setup_command" trackinglabel="code_instruction" /> - - <gl-sprintf-stub - message="For more information on the PyPi registry, %{linkStart}see the documentation%{linkEnd}." - /> + For more information on the PyPi registry, + <gl-link-stub + href="/help/user/packages/pypi_repository/index" + target="_blank" + > + see the documentation + </gl-link-stub> + . </div> `; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/composer_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/composer_installation_spec.js index aedf20e873a..0aba8f7efc7 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/composer_installation_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/composer_installation_spec.js @@ -7,6 +7,7 @@ import { TRACKING_ACTION_COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND, TRACKING_ACTION_COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND, PACKAGE_TYPE_COMPOSER, + COMPOSER_HELP_PATH, } from '~/packages_and_registries/package_registry/constants'; const packageEntity = { ...packageData(), packageType: PACKAGE_TYPE_COMPOSER }; @@ -24,9 +25,6 @@ describe('ComposerInstallation', () => { function createComponent(groupListUrl = 'groupListUrl') { wrapper = shallowMountExtended(ComposerInstallation, { provide: { - composerHelpPath: 'composerHelpPath', - composerConfigRepositoryName: 'composerConfigRepositoryName', - composerPath: 'composerPath', groupListUrl, }, propsData: { packageEntity }, @@ -61,7 +59,7 @@ describe('ComposerInstallation', () => { const registryIncludeCommand = findRegistryInclude(); expect(registryIncludeCommand.exists()).toBe(true); expect(registryIncludeCommand.props()).toMatchObject({ - instruction: `composer config repositories.composerConfigRepositoryName '{"type": "composer", "url": "composerPath"}'`, + instruction: `composer config repositories.${packageEntity.composerConfigRepositoryUrl} '{"type": "composer", "url": "${packageEntity.composerUrl}"}'`, copyText: 'Copy registry include', trackingAction: TRACKING_ACTION_COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND, }); @@ -96,7 +94,7 @@ describe('ComposerInstallation', () => { 'For more information on Composer packages in GitLab, see the documentation.', ); expect(findHelpLink().attributes()).toMatchObject({ - href: 'composerHelpPath', + href: COMPOSER_HELP_PATH, target: '_blank', }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/conan_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/conan_installation_spec.js index 6b642cc21b7..bf9425def9a 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/conan_installation_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/conan_installation_spec.js @@ -1,8 +1,12 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { packageData } from 'jest/packages_and_registries/package_registry/mock_data'; import ConanInstallation from '~/packages_and_registries/package_registry/components/details/conan_installation.vue'; import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue'; -import { PACKAGE_TYPE_CONAN } from '~/packages_and_registries/package_registry/constants'; +import { + PACKAGE_TYPE_CONAN, + CONAN_HELP_PATH, +} from '~/packages_and_registries/package_registry/constants'; import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; const packageEntity = { ...packageData(), packageType: PACKAGE_TYPE_CONAN }; @@ -12,16 +16,16 @@ describe('ConanInstallation', () => { const findCodeInstructions = () => wrapper.findAllComponents(CodeInstructions); const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); + const findSetupDocsLink = () => wrapper.findComponent(GlLink); function createComponent() { wrapper = shallowMountExtended(ConanInstallation, { - provide: { - conanHelpPath: 'conanHelpPath', - conanPath: 'conanPath', - }, propsData: { packageEntity, }, + stubs: { + GlSprintf, + }, }); } @@ -58,8 +62,15 @@ describe('ConanInstallation', () => { describe('setup commands', () => { it('renders the correct command', () => { expect(findCodeInstructions().at(1).props('instruction')).toBe( - 'conan remote add gitlab conanPath', + `conan remote add gitlab ${packageEntity.conanUrl}`, ); }); + + it('has a link to the docs', () => { + expect(findSetupDocsLink().attributes()).toMatchObject({ + href: CONAN_HELP_PATH, + target: '_blank', + }); + }); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/file_sha_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/file_sha_spec.js index ebfbbe5b864..feed7a7c46c 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/file_sha_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/file_sha_spec.js @@ -4,6 +4,8 @@ import FileSha from '~/packages_and_registries/package_registry/components/detai import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; +jest.mock('lodash/uniqueId', () => (prefix) => (prefix ? `${prefix}1` : 1)); + describe('FileSha', () => { let wrapper; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/maven_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/maven_installation_spec.js index eed7e903833..fc60039db30 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/maven_installation_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/maven_installation_spec.js @@ -1,3 +1,4 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -16,6 +17,7 @@ import { TRACKING_ACTION_COPY_KOTLIN_INSTALL_COMMAND, TRACKING_ACTION_COPY_KOTLIN_ADD_TO_SOURCE_COMMAND, PACKAGE_TYPE_MAVEN, + MAVEN_HELP_PATH, } from '~/packages_and_registries/package_registry/constants'; import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; @@ -28,9 +30,6 @@ describe('MavenInstallation', () => { metadata: mavenMetadata(), }; - const mavenHelpPath = 'mavenHelpPath'; - const mavenPath = 'mavenPath'; - const xmlCodeBlock = `<dependency> <groupId>appGroup</groupId> <artifactId>appName</artifactId> @@ -40,43 +39,43 @@ describe('MavenInstallation', () => { const mavenSetupXml = `<repositories> <repository> <id>gitlab-maven</id> - <url>${mavenPath}</url> + <url>${packageEntity.mavenUrl}</url> </repository> </repositories> <distributionManagement> <repository> <id>gitlab-maven</id> - <url>${mavenPath}</url> + <url>${packageEntity.mavenUrl}</url> </repository> <snapshotRepository> <id>gitlab-maven</id> - <url>${mavenPath}</url> + <url>${packageEntity.mavenUrl}</url> </snapshotRepository> </distributionManagement>`; const gradleGroovyInstallCommandText = `implementation 'appGroup:appName:appVersion'`; const gradleGroovyAddSourceCommandText = `maven { - url '${mavenPath}' + url '${packageEntity.mavenUrl}' }`; const gradleKotlinInstallCommandText = `implementation("appGroup:appName:appVersion")`; - const gradleKotlinAddSourceCommandText = `maven("${mavenPath}")`; + const gradleKotlinAddSourceCommandText = `maven("${packageEntity.mavenUrl}")`; const findCodeInstructions = () => wrapper.findAllComponents(CodeInstructions); const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); + const findSetupDocsLink = () => wrapper.findComponent(GlLink); function createComponent({ data = {} } = {}) { wrapper = shallowMountExtended(MavenInstallation, { - provide: { - mavenHelpPath, - mavenPath, - }, propsData: { packageEntity, }, data() { return data; }, + stubs: { + GlSprintf, + }, }); } @@ -148,6 +147,13 @@ describe('MavenInstallation', () => { trackingAction: TRACKING_ACTION_COPY_MAVEN_SETUP, }); }); + + it('has a setup link', () => { + expect(findSetupDocsLink().attributes()).toMatchObject({ + href: MAVEN_HELP_PATH, + target: '_blank', + }); + }); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js index b89410ede13..8c0e2d948ca 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js @@ -1,4 +1,4 @@ -import { GlFormRadioGroup } from '@gitlab/ui'; +import { GlLink, GlSprintf, GlFormRadioGroup } from '@gitlab/ui'; import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -15,6 +15,7 @@ import { YARN_PACKAGE_MANAGER, PROJECT_PACKAGE_ENDPOINT_TYPE, INSTANCE_PACKAGE_ENDPOINT_TYPE, + NPM_HELP_PATH, } from '~/packages_and_registries/package_registry/constants'; import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; @@ -29,13 +30,12 @@ describe('NpmInstallation', () => { const findCodeInstructions = () => wrapper.findAllComponents(CodeInstructions); const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); const findEndPointTypeSector = () => wrapper.findComponent(GlFormRadioGroup); + const findSetupDocsLink = () => wrapper.findComponent(GlLink); function createComponent({ data = {} } = {}) { wrapper = shallowMountExtended(NpmInstallation, { provide: { - npmHelpPath: 'npmHelpPath', - npmPath: 'npmPath', - npmProjectPath: 'npmProjectPath', + npmInstanceUrl: 'npmInstanceUrl', }, propsData: { packageEntity, @@ -43,6 +43,7 @@ describe('NpmInstallation', () => { data() { return data; }, + stubs: { GlSprintf }, }); } @@ -58,6 +59,13 @@ describe('NpmInstallation', () => { expect(wrapper.element).toMatchSnapshot(); }); + it('has a setup link', () => { + expect(findSetupDocsLink().attributes()).toMatchObject({ + href: NPM_HELP_PATH, + target: '_blank', + }); + }); + describe('endpoint type selector', () => { it('has the endpoint type selector', () => { expect(findEndPointTypeSector().exists()).toBe(true); @@ -109,7 +117,7 @@ describe('NpmInstallation', () => { it('renders the correct setup command', () => { expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: 'echo @gitlab-org:registry=npmPath/ >> .npmrc', + instruction: 'echo @gitlab-org:registry=npmInstanceUrl/ >> .npmrc', multiline: false, trackingAction: TRACKING_ACTION_COPY_NPM_SETUP_COMMAND, }); @@ -121,7 +129,7 @@ describe('NpmInstallation', () => { await nextTick(); expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: `echo @gitlab-org:registry=npmProjectPath/ >> .npmrc`, + instruction: `echo @gitlab-org:registry=${packageEntity.npmUrl}/ >> .npmrc`, multiline: false, trackingAction: TRACKING_ACTION_COPY_NPM_SETUP_COMMAND, }); @@ -131,7 +139,7 @@ describe('NpmInstallation', () => { await nextTick(); expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: `echo @gitlab-org:registry=npmPath/ >> .npmrc`, + instruction: `echo @gitlab-org:registry=npmInstanceUrl/ >> .npmrc`, multiline: false, trackingAction: TRACKING_ACTION_COPY_NPM_SETUP_COMMAND, }); @@ -153,7 +161,7 @@ describe('NpmInstallation', () => { it('renders the correct registry command', () => { expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: 'echo \\"@gitlab-org:registry\\" \\"npmPath/\\" >> .yarnrc', + instruction: 'echo \\"@gitlab-org:registry\\" \\"npmInstanceUrl/\\" >> .yarnrc', multiline: false, trackingAction: TRACKING_ACTION_COPY_YARN_SETUP_COMMAND, }); @@ -165,7 +173,7 @@ describe('NpmInstallation', () => { await nextTick(); expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: `echo \\"@gitlab-org:registry\\" \\"npmProjectPath/\\" >> .yarnrc`, + instruction: `echo \\"@gitlab-org:registry\\" \\"${packageEntity.npmUrl}/\\" >> .yarnrc`, multiline: false, trackingAction: TRACKING_ACTION_COPY_YARN_SETUP_COMMAND, }); @@ -175,7 +183,7 @@ describe('NpmInstallation', () => { await nextTick(); expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: 'echo \\"@gitlab-org:registry\\" \\"npmPath/\\" >> .yarnrc', + instruction: 'echo \\"@gitlab-org:registry\\" \\"npmInstanceUrl/\\" >> .yarnrc', multiline: false, trackingAction: TRACKING_ACTION_COPY_YARN_SETUP_COMMAND, }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/nuget_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/nuget_installation_spec.js index c48a3f07299..d324d43258c 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/nuget_installation_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/nuget_installation_spec.js @@ -1,3 +1,4 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { packageData } from 'jest/packages_and_registries/package_registry/mock_data'; import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue'; @@ -6,6 +7,7 @@ import { TRACKING_ACTION_COPY_NUGET_INSTALL_COMMAND, TRACKING_ACTION_COPY_NUGET_SETUP_COMMAND, PACKAGE_TYPE_NUGET, + NUGET_HELP_PATH, } from '~/packages_and_registries/package_registry/constants'; import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; @@ -15,21 +17,18 @@ describe('NugetInstallation', () => { let wrapper; const nugetInstallationCommandStr = 'nuget install @gitlab-org/package-15 -Source "GitLab"'; - const nugetSetupCommandStr = - 'nuget source Add -Name "GitLab" -Source "nugetPath" -UserName <your_username> -Password <your_token>'; + const nugetSetupCommandStr = `nuget source Add -Name "GitLab" -Source "${packageEntity.nugetUrl}" -UserName <your_username> -Password <your_token>`; const findCodeInstructions = () => wrapper.findAllComponents(CodeInstructions); const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); + const findSetupDocsLink = () => wrapper.findComponent(GlLink); function createComponent() { wrapper = shallowMountExtended(NugetInstallation, { - provide: { - nugetHelpPath: 'nugetHelpPath', - nugetPath: 'nugetPath', - }, propsData: { packageEntity, }, + stubs: { GlSprintf }, }); } @@ -71,5 +70,12 @@ describe('NugetInstallation', () => { trackingAction: TRACKING_ACTION_COPY_NUGET_SETUP_COMMAND, }); }); + + it('it has docs link', () => { + expect(findSetupDocsLink().attributes()).toMatchObject({ + href: NUGET_HELP_PATH, + target: '_blank', + }); + }); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js index 042b2026199..f8a4ba8f3bc 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js @@ -28,8 +28,8 @@ describe('Package Files', () => { const createComponent = ({ packageFiles = [file], canDelete = true } = {}) => { wrapper = mountExtended(PackageFiles, { - provide: { canDelete }, propsData: { + canDelete, packageFiles, }, stubs: { diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js index 410c1b65348..f2fef6436a6 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js @@ -1,3 +1,4 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { packageData } from 'jest/packages_and_registries/package_registry/mock_data'; import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue'; @@ -6,6 +7,7 @@ import { PACKAGE_TYPE_PYPI, TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND, TRACKING_ACTION_COPY_PYPI_SETUP_COMMAND, + PYPI_HELP_PATH, } from '~/packages_and_registries/package_registry/constants'; const packageEntity = { ...packageData(), packageType: PACKAGE_TYPE_PYPI }; @@ -13,9 +15,9 @@ const packageEntity = { ...packageData(), packageType: PACKAGE_TYPE_PYPI }; describe('PypiInstallation', () => { let wrapper; - const pipCommandStr = 'pip install @gitlab-org/package-15 --extra-index-url pypiPath'; + const pipCommandStr = `pip install @gitlab-org/package-15 --extra-index-url ${packageEntity.pypiUrl}`; const pypiSetupStr = `[gitlab] -repository = pypiSetupPath +repository = ${packageEntity.pypiSetupUrl} username = __token__ password = <your personal access token>`; @@ -23,17 +25,16 @@ password = <your personal access token>`; const setupInstruction = () => wrapper.findByTestId('pypi-setup-content'); const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); + const findSetupDocsLink = () => wrapper.findComponent(GlLink); function createComponent() { wrapper = shallowMountExtended(PypiInstallation, { - provide: { - pypiHelpPath: 'pypiHelpPath', - pypiPath: 'pypiPath', - pypiSetupPath: 'pypiSetupPath', - }, propsData: { packageEntity, }, + stubs: { + GlSprintf, + }, }); } @@ -76,5 +77,12 @@ password = <your personal access token>`; trackingAction: TRACKING_ACTION_COPY_PYPI_SETUP_COMMAND, }); }); + + it('has a link to the docs', () => { + expect(findSetupDocsLink().attributes()).toMatchObject({ + href: PYPI_HELP_PATH, + target: '_blank', + }); + }); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap index 165ee962417..18a99f70756 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap @@ -22,16 +22,20 @@ exports[`packages_list_row renders 1`] = ` <div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0" > - <gl-link-stub + <router-link-stub + ariacurrentvalue="page" class="gl-text-body gl-min-w-0" data-qa-selector="package_link" - href="http://gdk.test:3000/gitlab-org/gitlab-test/-/packages/111" + data-testid="details-link" + event="click" + tag="a" + to="[object Object]" > <gl-truncate-stub position="end" text="@gitlab-org/package-15" /> - </gl-link-stub> + </router-link-stub> <!----> diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js index 292667ec47c..9467a613b2a 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js @@ -1,7 +1,11 @@ -import { GlLink, GlSprintf } from '@gitlab/ui'; +import { GlSprintf } from '@gitlab/ui'; +import { createLocalVue } from '@vue/test-utils'; +import VueRouter from 'vue-router'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue'; import PackagePath from '~/packages_and_registries/shared/components/package_path.vue'; import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; @@ -13,6 +17,9 @@ import { PACKAGE_ERROR_STATUS } from '~/packages_and_registries/package_registry import ListItem from '~/vue_shared/components/registry/list_item.vue'; import { packageData, packagePipelines, packageProject, packageTags } from '../../mock_data'; +const localVue = createLocalVue(); +localVue.use(VueRouter); + describe('packages_list_row', () => { let wrapper; @@ -28,7 +35,7 @@ describe('packages_list_row', () => { const findDeleteButton = () => wrapper.findByTestId('action-delete'); const findPackageIconAndName = () => wrapper.find(PackageIconAndName); const findListItem = () => wrapper.findComponent(ListItem); - const findPackageLink = () => wrapper.findComponent(GlLink); + const findPackageLink = () => wrapper.findByTestId('details-link'); const findWarningIcon = () => wrapper.findByTestId('warning-icon'); const findLeftSecondaryInfos = () => wrapper.findByTestId('left-secondary-infos'); const findPublishMethod = () => wrapper.findComponent(PublishMethod); @@ -40,6 +47,7 @@ describe('packages_list_row', () => { provide = defaultProvide, } = {}) => { wrapper = shallowMountExtended(PackagesListRow, { + localVue, provide, stubs: { ListItem, @@ -63,6 +71,15 @@ describe('packages_list_row', () => { expect(wrapper.element).toMatchSnapshot(); }); + it('has a link to navigate to the details page', () => { + mountComponent(); + + expect(findPackageLink().props()).toMatchObject({ + event: 'click', + to: { name: 'details', params: { id: getIdFromGraphQLId(packageWithoutTags.id) } }, + }); + }); + describe('tags', () => { it('renders package tags when a package has tags', () => { mountComponent({ packageEntity: packageWithTags }); @@ -120,7 +137,7 @@ describe('packages_list_row', () => { }); it('details link is disabled', () => { - expect(findPackageLink().attributes('disabled')).toBe('true'); + expect(findPackageLink().props('event')).toBe(''); }); it('has a warning icon', () => { diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js index 4c23b52b8a2..c6a59f20998 100644 --- a/spec/frontend/packages_and_registries/package_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -120,12 +120,22 @@ export const packageVersions = () => [ export const packageData = (extend) => ({ id: 'gid://gitlab/Packages::Package/111', + canDestroy: true, name: '@gitlab-org/package-15', packageType: 'NPM', version: '1.0.0', createdAt: '2020-08-17T14:23:32Z', updatedAt: '2020-08-17T14:23:32Z', status: 'DEFAULT', + mavenUrl: 'http://gdk.test:3000/api/v4/projects/1/packages/maven', + npmUrl: 'http://gdk.test:3000/api/v4/projects/1/packages/npm', + nugetUrl: 'http://gdk.test:3000/api/v4/projects/1/packages/nuget/index.json', + composerConfigRepositoryUrl: 'gdk.test/22', + composerUrl: 'http://gdk.test:3000/api/v4/group/22/-/packages/composer/packages.json', + conanUrl: 'http://gdk.test:3000/api/v4/projects/1/packages/conan', + pypiUrl: + 'http://__token__:<your_personal_token>@gdk.test:3000/api/v4/projects/1/packages/pypi/simple', + pypiSetupUrl: 'http://gdk.test:3000/api/v4/projects/1/packages/pypi', ...extend, }); @@ -185,6 +195,7 @@ export const packageDetailsQuery = (extendPackage) => ({ project: { id: '1', path: 'projectPath', + name: 'gitlab-test', }, tags: { nodes: packageTags(), diff --git a/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap index dbe3c70c3cb..ed96abe24b1 100644 --- a/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap @@ -11,10 +11,10 @@ exports[`PackagesListApp renders 1`] = ` <div> <section - class="row empty-state text-center" + class="gl-display-flex empty-state gl-text-center gl-flex-direction-column" > <div - class="col-12" + class="gl-max-w-full" > <div class="svg-250 svg-content" @@ -29,10 +29,10 @@ exports[`PackagesListApp renders 1`] = ` </div> <div - class="col-12" + class="gl-max-w-full gl-m-auto" > <div - class="text-content gl-mx-auto gl-my-0 gl-p-5" + class="gl-mx-auto gl-my-0 gl-p-5" > <h1 class="gl-font-size-h-display gl-line-height-36 h4" diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js b/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js index 0bea84693f6..637e2edf3be 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js @@ -9,7 +9,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import AdditionalMetadata from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue'; -import PackagesApp from '~/packages_and_registries/package_registry/components/details/app.vue'; +import PackagesApp from '~/packages_and_registries/package_registry/pages/details.vue'; import DependencyRow from '~/packages_and_registries/package_registry/components/details/dependency_row.vue'; import InstallationCommands from '~/packages_and_registries/package_registry/components/details/installation_commands.vue'; import PackageFiles from '~/packages_and_registries/package_registry/components/details/package_files.vue'; @@ -36,7 +36,7 @@ import { packageFiles, packageDestroyFileMutation, packageDestroyFileMutationError, -} from '../../mock_data'; +} from '../mock_data'; jest.mock('~/flash'); useMockLocationHelper(); @@ -47,21 +47,22 @@ describe('PackagesApp', () => { let wrapper; let apolloProvider; + const breadCrumbState = { + updateName: jest.fn(), + }; + const provide = { packageId: '111', - titleComponent: 'PackageTitle', - projectName: 'projectName', - canDelete: 'canDelete', - svgPath: 'svgPath', - npmPath: 'npmPath', - npmHelpPath: 'npmHelpPath', + emptyListIllustration: 'svgPath', projectListUrl: 'projectListUrl', groupListUrl: 'groupListUrl', + breadCrumbState, }; function createComponent({ resolver = jest.fn().mockResolvedValue(packageDetailsQuery()), fileDeleteMutationResolver = jest.fn().mockResolvedValue(packageDestroyFileMutation()), + routeId = '1', } = {}) { localVue.use(VueApollo); @@ -87,6 +88,13 @@ describe('PackagesApp', () => { GlTabs, GlTab, }, + mocks: { + $route: { + params: { + id: routeId, + }, + }, + }, }); } @@ -149,7 +157,7 @@ describe('PackagesApp', () => { expect(findPackageHistory().exists()).toBe(true); expect(findPackageHistory().props()).toMatchObject({ packageEntity: expect.objectContaining(packageData()), - projectName: provide.projectName, + projectName: packageDetailsQuery().data.package.project.name, }); }); @@ -175,9 +183,18 @@ describe('PackagesApp', () => { }); }); + it('calls the appropriate function to set the breadcrumbState', async () => { + const { name, version } = packageData(); + createComponent(); + + await waitForPromises(); + + expect(breadCrumbState.updateName).toHaveBeenCalledWith(`${name} v ${version}`); + }); + describe('delete package', () => { const originalReferrer = document.referrer; - const setReferrer = (value = provide.projectName) => { + const setReferrer = (value = packageDetailsQuery().data.package.project.name) => { Object.defineProperty(document, 'referrer', { value, configurable: true, @@ -244,6 +261,7 @@ describe('PackagesApp', () => { expect(findPackageFiles().exists()).toBe(true); expect(findPackageFiles().props('packageFiles')[0]).toMatchObject(expectedFile); + expect(findPackageFiles().props('canDelete')).toBe(packageData().canDestroy); }); it('does not render the package files table when the package is composer', async () => { diff --git a/spec/frontend/packages_and_registries/shared/__snapshots__/publish_method_spec.js.snap b/spec/frontend/packages_and_registries/shared/components/__snapshots__/publish_method_spec.js.snap index 5f243799bae..5f243799bae 100644 --- a/spec/frontend/packages_and_registries/shared/__snapshots__/publish_method_spec.js.snap +++ b/spec/frontend/packages_and_registries/shared/components/__snapshots__/publish_method_spec.js.snap diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap b/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap index 7044c1285d8..ceae8eebaef 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap +++ b/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap @@ -1,7 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Registry Breadcrumb when is not rootRoute renders 1`] = ` -<div +<nav + aria-label="Breadcrumb" class="gl-breadcrumbs" > @@ -24,19 +25,25 @@ exports[`Registry Breadcrumb when is not rootRoute renders 1`] = ` class="gl-breadcrumb-separator" data-testid="separator" > - <svg - aria-hidden="true" - class="gl-icon s8" - data-testid="angle-right-icon" - role="img" + <span + class="gl-mx-n5" > - <use - href="#angle-right" - /> - </svg> + <svg + aria-hidden="true" + class="gl-icon s8" + data-testid="angle-right-icon" + role="img" + > + <use + href="#angle-right" + /> + </svg> + </span> </span> </a> </li> + + <!----> <li class="breadcrumb-item gl-breadcrumb-item" > @@ -52,12 +59,15 @@ exports[`Registry Breadcrumb when is not rootRoute renders 1`] = ` <!----> </a> </li> + + <!----> </ol> -</div> +</nav> `; exports[`Registry Breadcrumb when is rootRoute renders 1`] = ` -<div +<nav + aria-label="Breadcrumb" class="gl-breadcrumbs" > @@ -79,6 +89,8 @@ exports[`Registry Breadcrumb when is rootRoute renders 1`] = ` <!----> </a> </li> + + <!----> </ol> -</div> +</nav> `; diff --git a/spec/frontend/packages_and_registries/shared/package_icon_and_name_spec.js b/spec/frontend/packages_and_registries/shared/components/package_icon_and_name_spec.js index d6d1970cb12..d6d1970cb12 100644 --- a/spec/frontend/packages_and_registries/shared/package_icon_and_name_spec.js +++ b/spec/frontend/packages_and_registries/shared/components/package_icon_and_name_spec.js diff --git a/spec/frontend/packages_and_registries/shared/package_path_spec.js b/spec/frontend/packages_and_registries/shared/components/package_path_spec.js index 93425d4f399..93425d4f399 100644 --- a/spec/frontend/packages_and_registries/shared/package_path_spec.js +++ b/spec/frontend/packages_and_registries/shared/components/package_path_spec.js diff --git a/spec/frontend/packages_and_registries/shared/package_tags_spec.js b/spec/frontend/packages_and_registries/shared/components/package_tags_spec.js index 33e96c0775e..33e96c0775e 100644 --- a/spec/frontend/packages_and_registries/shared/package_tags_spec.js +++ b/spec/frontend/packages_and_registries/shared/components/package_tags_spec.js diff --git a/spec/frontend/packages_and_registries/shared/packages_list_loader_spec.js b/spec/frontend/packages_and_registries/shared/components/packages_list_loader_spec.js index 0005162e0bb..0005162e0bb 100644 --- a/spec/frontend/packages_and_registries/shared/packages_list_loader_spec.js +++ b/spec/frontend/packages_and_registries/shared/components/packages_list_loader_spec.js diff --git a/spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js b/spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js new file mode 100644 index 00000000000..bd492a5ae8f --- /dev/null +++ b/spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js @@ -0,0 +1,145 @@ +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; +import component from '~/packages_and_registries/shared/components/persisted_search.vue'; +import UrlSync from '~/vue_shared/components/url_sync.vue'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; +import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils'; + +jest.mock('~/packages_and_registries/shared/utils'); + +useMockLocationHelper(); + +describe('Persisted Search', () => { + let wrapper; + + const defaultQueryParamsMock = { + filters: ['foo'], + sorting: { sort: 'desc', orderBy: 'test' }, + }; + + const defaultProps = { + sortableFields: [ + { orderBy: 'test', label: 'test' }, + { orderBy: 'foo', label: 'foo' }, + ], + defaultOrder: 'test', + defaultSort: 'asc', + }; + + const findRegistrySearch = () => wrapper.findComponent(RegistrySearch); + const findUrlSync = () => wrapper.findComponent(UrlSync); + + const mountComponent = (propsData = defaultProps) => { + wrapper = shallowMountExtended(component, { + propsData, + stubs: { + UrlSync, + }, + }); + }; + + beforeEach(() => { + extractFilterAndSorting.mockReturnValue(defaultQueryParamsMock); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('has a registry search component', async () => { + mountComponent(); + + await nextTick(); + + expect(findRegistrySearch().exists()).toBe(true); + }); + + it('registry search is mounted after mount', async () => { + mountComponent(); + + expect(findRegistrySearch().exists()).toBe(false); + }); + + it('has a UrlSync component', () => { + mountComponent(); + + expect(findUrlSync().exists()).toBe(true); + }); + + it('on sorting:changed emits update event and update internal sort', async () => { + const payload = { sort: 'desc', orderBy: 'test' }; + + mountComponent(); + + await nextTick(); + + findRegistrySearch().vm.$emit('sorting:changed', payload); + + await nextTick(); + + expect(findRegistrySearch().props('sorting')).toMatchObject(payload); + + // there is always a first call on mounted that emits up default values + expect(wrapper.emitted('update')[1]).toEqual([ + { + filters: ['foo'], + sort: 'TEST_DESC', + }, + ]); + }); + + it('on filter:changed updates the filters', async () => { + const payload = ['foo']; + + mountComponent(); + + await nextTick(); + + findRegistrySearch().vm.$emit('filter:changed', payload); + + await nextTick(); + + expect(findRegistrySearch().props('filter')).toEqual(['foo']); + }); + + it('on filter:submit emits update event', async () => { + mountComponent(); + + await nextTick(); + + findRegistrySearch().vm.$emit('filter:submit'); + + expect(wrapper.emitted('update')[1]).toEqual([ + { + filters: ['foo'], + sort: 'TEST_DESC', + }, + ]); + }); + + it('on query:changed calls updateQuery from UrlSync', async () => { + jest.spyOn(UrlSync.methods, 'updateQuery').mockImplementation(() => {}); + + mountComponent(); + + await nextTick(); + + findRegistrySearch().vm.$emit('query:changed'); + + expect(UrlSync.methods.updateQuery).toHaveBeenCalled(); + }); + + it('sets the component sorting and filtering based on the querystring', async () => { + mountComponent(); + + await nextTick(); + + expect(getQueryParams).toHaveBeenCalled(); + + expect(findRegistrySearch().props()).toMatchObject({ + filter: defaultQueryParamsMock.filters, + sorting: defaultQueryParamsMock.sorting, + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/shared/publish_method_spec.js b/spec/frontend/packages_and_registries/shared/components/publish_method_spec.js index fa8f8f7641a..fa8f8f7641a 100644 --- a/spec/frontend/packages_and_registries/shared/publish_method_spec.js +++ b/spec/frontend/packages_and_registries/shared/components/publish_method_spec.js diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/registry_breadcrumb_spec.js b/spec/frontend/packages_and_registries/shared/components/registry_breadcrumb_spec.js index e5a8438f23f..6dfe116c285 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/registry_breadcrumb_spec.js +++ b/spec/frontend/packages_and_registries/shared/components/registry_breadcrumb_spec.js @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils'; -import component from '~/packages_and_registries/container_registry/explorer/components/registry_breadcrumb.vue'; +import component from '~/packages_and_registries/shared/components/registry_breadcrumb.vue'; describe('Registry Breadcrumb', () => { let wrapper; diff --git a/spec/frontend/pages/dashboard/todos/index/todos_spec.js b/spec/frontend/pages/dashboard/todos/index/todos_spec.js index 5bba98bdf96..6a7ce80ec5a 100644 --- a/spec/frontend/pages/dashboard/todos/index/todos_spec.js +++ b/spec/frontend/pages/dashboard/todos/index/todos_spec.js @@ -94,13 +94,13 @@ describe('Todos', () => { }); it('updates pending text', () => { - expect(document.querySelector('.js-todos-pending .badge').innerHTML).toEqual( + expect(document.querySelector('.js-todos-pending .js-todos-badge').innerHTML).toEqual( addDelimiter(TEST_COUNT_BIG), ); }); it('updates done text', () => { - expect(document.querySelector('.js-todos-done .badge').innerHTML).toEqual( + expect(document.querySelector('.js-todos-done .js-todos-badge').innerHTML).toEqual( addDelimiter(TEST_DONE_COUNT_BIG), ); }); diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js index 53c1733eab9..b700c255e8c 100644 --- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js @@ -38,14 +38,14 @@ describe('Timezone Dropdown', () => { const tzStr = '[UTC + 5.5] Sri Jayawardenepura'; const tzValue = 'Asia/Colombo'; - expect($inputEl.val()).toBe('UTC'); + expect($inputEl.val()).toBe('Etc/UTC'); $(`${tzListSel}:contains('${tzStr}')`, $wrapper).trigger('click'); const val = $inputEl.val(); expect(val).toBe(tzValue); - expect(val).not.toBe('UTC'); + expect(val).not.toBe('Etc/UTC'); }); it('will format data array of timezones into a list of offsets', () => { @@ -67,7 +67,7 @@ describe('Timezone Dropdown', () => { it('will default the timezone to UTC', () => { const tz = $inputEl.val(); - expect(tz).toBe('UTC'); + expect(tz).toBe('Etc/UTC'); }); }); 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 0020269e4e7..8a9bb025d55 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 @@ -7,6 +7,7 @@ import { visibilityLevelDescriptions, visibilityOptions, } from '~/pages/projects/shared/permissions/constants'; +import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue'; const defaultProps = { currentSettings: { @@ -47,6 +48,8 @@ const defaultProps = { packagesAvailable: false, packagesHelpPath: '/help/user/packages/index', requestCveAvailable: true, + confirmationPhrase: 'my-fake-project', + showVisibilityConfirmModal: false, }; describe('Settings Panel', () => { @@ -104,6 +107,7 @@ describe('Settings Panel', () => { ); const findMetricsVisibilitySettings = () => wrapper.find({ ref: 'metrics-visibility-settings' }); const findOperationsSettings = () => wrapper.find({ ref: 'operations-settings' }); + const findConfirmDangerButton = () => wrapper.findComponent(ConfirmDanger); afterEach(() => { wrapper.destroy(); @@ -177,6 +181,44 @@ describe('Settings Panel', () => { expect(findRequestAccessEnabledInput().exists()).toBe(false); }); + + it('does not require confirmation if the visibility is reduced', async () => { + wrapper = mountComponent({ + currentSettings: { visibilityLevel: visibilityOptions.INTERNAL }, + }); + + expect(findConfirmDangerButton().exists()).toBe(false); + + await findProjectVisibilityLevelInput().setValue(visibilityOptions.PRIVATE); + + expect(findConfirmDangerButton().exists()).toBe(false); + }); + + describe('showVisibilityConfirmModal=true', () => { + beforeEach(() => { + wrapper = mountComponent({ + currentSettings: { visibilityLevel: visibilityOptions.INTERNAL }, + showVisibilityConfirmModal: true, + }); + }); + + it('will render the confirmation dialog if the visibility is reduced', async () => { + expect(findConfirmDangerButton().exists()).toBe(false); + + await findProjectVisibilityLevelInput().setValue(visibilityOptions.PRIVATE); + + expect(findConfirmDangerButton().exists()).toBe(true); + }); + + it('emits the `confirm` event when the reduce visibility warning is confirmed', async () => { + expect(wrapper.emitted('confirm')).toBeUndefined(); + + await findProjectVisibilityLevelInput().setValue(visibilityOptions.PRIVATE); + await findConfirmDangerButton().vm.$emit('confirm'); + + expect(wrapper.emitted('confirm')).toHaveLength(1); + }); + }); }); describe('Issues settings', () => { diff --git a/spec/frontend/pages/shared/nav/sidebar_tracking_spec.js b/spec/frontend/pages/shared/nav/sidebar_tracking_spec.js index 2c8eb8e459f..04f53e048ed 100644 --- a/spec/frontend/pages/shared/nav/sidebar_tracking_spec.js +++ b/spec/frontend/pages/shared/nav/sidebar_tracking_spec.js @@ -57,9 +57,9 @@ describe('~/pages/shared/nav/sidebar_tracking.js', () => { menu.classList.add('is-over', 'is-showing-fly-out'); menuLink.click(); - expect(menu.dataset).toMatchObject({ - trackAction: 'click_menu', - trackExtra: JSON.stringify({ + expect(menu).toHaveTrackingAttributes({ + action: 'click_menu', + extra: JSON.stringify({ sidebar_display: 'Expanded', menu_display: 'Fly out', }), @@ -74,9 +74,9 @@ describe('~/pages/shared/nav/sidebar_tracking.js', () => { submenuList.classList.add('fly-out-list'); menuLink.click(); - expect(menu.dataset).toMatchObject({ - trackAction: 'click_menu_item', - trackExtra: JSON.stringify({ + expect(menu).toHaveTrackingAttributes({ + action: 'click_menu_item', + extra: JSON.stringify({ sidebar_display: 'Expanded', menu_display: 'Fly out', }), @@ -92,9 +92,9 @@ describe('~/pages/shared/nav/sidebar_tracking.js', () => { menu.classList.add('active'); menuLink.click(); - expect(menu.dataset).toMatchObject({ - trackAction: 'click_menu', - trackExtra: JSON.stringify({ + expect(menu).toHaveTrackingAttributes({ + action: 'click_menu', + extra: JSON.stringify({ sidebar_display: 'Expanded', menu_display: 'Expanded', }), @@ -108,9 +108,9 @@ describe('~/pages/shared/nav/sidebar_tracking.js', () => { menu.classList.add('active'); menuLink.click(); - expect(menu.dataset).toMatchObject({ - trackAction: 'click_menu_item', - trackExtra: JSON.stringify({ + expect(menu).toHaveTrackingAttributes({ + action: 'click_menu_item', + extra: JSON.stringify({ sidebar_display: 'Expanded', menu_display: 'Expanded', }), @@ -131,9 +131,9 @@ describe('~/pages/shared/nav/sidebar_tracking.js', () => { menu.classList.add('is-over', 'is-showing-fly-out'); menuLink.click(); - expect(menu.dataset).toMatchObject({ - trackAction: 'click_menu', - trackExtra: JSON.stringify({ + expect(menu).toHaveTrackingAttributes({ + action: 'click_menu', + extra: JSON.stringify({ sidebar_display: 'Collapsed', menu_display: 'Fly out', }), @@ -148,9 +148,9 @@ describe('~/pages/shared/nav/sidebar_tracking.js', () => { submenuList.classList.add('fly-out-list'); menuLink.click(); - expect(menu.dataset).toMatchObject({ - trackAction: 'click_menu_item', - trackExtra: JSON.stringify({ + expect(menu).toHaveTrackingAttributes({ + action: 'click_menu_item', + extra: JSON.stringify({ sidebar_display: 'Collapsed', menu_display: 'Fly out', }), diff --git a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js index f4236146d33..fd581eebd1e 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js @@ -1,5 +1,5 @@ import { nextTick } from 'vue'; -import { GlLoadingIcon, GlModal } from '@gitlab/ui'; +import { GlLoadingIcon, GlModal, GlAlert, GlButton } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; @@ -31,25 +31,28 @@ describe('WikiForm', () => { const findContent = () => wrapper.find('#wiki_content'); const findMessage = () => wrapper.find('#wiki_message'); const findSubmitButton = () => wrapper.findByTestId('wiki-submit-button'); - const findCancelButton = () => wrapper.findByRole('link', { name: 'Cancel' }); - const findUseNewEditorButton = () => wrapper.findByRole('button', { name: 'Use the new editor' }); + const findCancelButton = () => wrapper.findByTestId('wiki-cancel-button'); + const findUseNewEditorButton = () => wrapper.findByText('Use the new editor'); const findToggleEditingModeButton = () => wrapper.findByTestId('toggle-editing-mode-button'); - const findDismissContentEditorAlertButton = () => - wrapper.findByRole('button', { name: 'Try this later' }); + const findDismissContentEditorAlertButton = () => wrapper.findByText('Try this later'); const findSwitchToOldEditorButton = () => wrapper.findByRole('button', { name: 'Switch me back to the classic editor.' }); - const findTitleHelpLink = () => wrapper.findByRole('link', { name: 'Learn more.' }); + const findTitleHelpLink = () => wrapper.findByText('Learn more.'); const findMarkdownHelpLink = () => wrapper.findByTestId('wiki-markdown-help-link'); const findContentEditor = () => wrapper.findComponent(ContentEditor); const findClassicEditor = () => wrapper.findComponent(MarkdownField); const setFormat = (value) => { const format = findFormat(); - format.find(`option[value=${value}]`).setSelected(); - format.element.dispatchEvent(new Event('change')); + + return format.find(`option[value=${value}]`).setSelected(); }; - const triggerFormSubmit = () => findForm().element.dispatchEvent(new Event('submit')); + const triggerFormSubmit = () => { + findForm().element.dispatchEvent(new Event('submit')); + + return nextTick(); + }; const dispatchBeforeUnload = () => { const e = new Event('beforeunload'); @@ -84,34 +87,14 @@ describe('WikiForm', () => { Org: 'org', }; - function createWrapper( - persisted = false, - { pageInfo, glFeatures = { wikiSwitchBetweenContentEditorRawMarkdown: false } } = {}, - ) { - wrapper = extendedWrapper( - mount( - WikiForm, - { - provide: { - formatOptions, - glFeatures, - pageInfo: { - ...(persisted ? pageInfoPersisted : pageInfoNew), - ...pageInfo, - }, - }, - }, - { attachToDocument: true }, - ), - ); - } - - const createShallowWrapper = ( + function createWrapper({ + mountFn = shallowMount, persisted = false, - { pageInfo, glFeatures = { wikiSwitchBetweenContentEditorRawMarkdown: false } } = {}, - ) => { + pageInfo, + glFeatures = { wikiSwitchBetweenContentEditorRawMarkdown: false }, + } = {}) { wrapper = extendedWrapper( - shallowMount(WikiForm, { + mountFn(WikiForm, { provide: { formatOptions, glFeatures, @@ -122,10 +105,12 @@ describe('WikiForm', () => { }, stubs: { MarkdownField, + GlAlert, + GlButton, }, }), ); - }; + } beforeEach(() => { trackingSpy = mockTracking(undefined, null, jest.spyOn); @@ -147,26 +132,24 @@ describe('WikiForm', () => { `( 'updates the commit message to $message when title is $title and persisted=$persisted', async ({ title, message, persisted }) => { - createWrapper(persisted); - - findTitle().setValue(title); + createWrapper({ persisted }); - await wrapper.vm.$nextTick(); + await findTitle().setValue(title); 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); + createWrapper({ persisted: true }); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findMessage().element.value).toBe('Update My page'); }); it('does not trim page content by default', () => { - createWrapper(true); + createWrapper({ persisted: true }); expect(findContent().element.value).toBe(' My page content '); }); @@ -178,20 +161,16 @@ describe('WikiForm', () => { ${'asciidoc'} | ${'link:page-slug[Link title]'} ${'org'} | ${'[[page-slug]]'} `('updates the link help message when format=$value is selected', async ({ value, text }) => { - createWrapper(); + createWrapper({ mountFn: mount }); - setFormat(value); - - await wrapper.vm.$nextTick(); + await setFormat(value); expect(wrapper.text()).toContain(text); }); - it('starts with no unload warning', async () => { + it('starts with no unload warning', () => { createWrapper(); - await wrapper.vm.$nextTick(); - const e = dispatchBeforeUnload(); expect(typeof e.returnValue).not.toBe('string'); expect(e.preventDefault).not.toHaveBeenCalled(); @@ -203,20 +182,16 @@ describe('WikiForm', () => { ${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(); + ({ persisted, titleHelpLink, titleHelpText }) => { + createWrapper({ persisted }); expect(wrapper.text()).toContain(titleHelpText); expect(findTitleHelpLink().attributes().href).toBe(titleHelpLink); }, ); - it('shows correct link for wiki specific markdown docs', async () => { - createWrapper(); - - await wrapper.vm.$nextTick(); + it('shows correct link for wiki specific markdown docs', () => { + createWrapper({ mountFn: mount }); expect(findMarkdownHelpLink().attributes().href).toBe( '/help/user/markdown#wiki-specific-markdown', @@ -225,12 +200,11 @@ describe('WikiForm', () => { describe('when wiki content is updated', () => { beforeEach(async () => { - createWrapper(true); + createWrapper({ mountFn: mount, persisted: true }); const input = findContent(); - input.setValue(' Lorem ipsum dolar sit! '); - await input.trigger('input'); + await input.setValue(' Lorem ipsum dolar sit! '); }); it('sets before unload warning', () => { @@ -241,17 +215,15 @@ describe('WikiForm', () => { describe('form submit', () => { beforeEach(async () => { - triggerFormSubmit(); - - await wrapper.vm.$nextTick(); + await triggerFormSubmit(); }); - it('when form submitted, unsets before unload warning', async () => { + it('when form submitted, unsets before unload warning', () => { const e = dispatchBeforeUnload(); expect(e.preventDefault).not.toHaveBeenCalled(); }); - it('triggers wiki format tracking event', async () => { + it('triggers wiki format tracking event', () => { expect(trackingSpy).toHaveBeenCalledTimes(1); }); @@ -264,22 +236,20 @@ describe('WikiForm', () => { describe('submit button state', () => { it.each` title | content | buttonState | disabledAttr - ${'something'} | ${'something'} | ${'enabled'} | ${undefined} - ${''} | ${'something'} | ${'disabled'} | ${'disabled'} - ${'something'} | ${''} | ${'disabled'} | ${'disabled'} - ${''} | ${''} | ${'disabled'} | ${'disabled'} - ${' '} | ${' '} | ${'disabled'} | ${'disabled'} + ${'something'} | ${'something'} | ${'enabled'} | ${false} + ${''} | ${'something'} | ${'disabled'} | ${true} + ${'something'} | ${''} | ${'disabled'} | ${true} + ${''} | ${''} | ${'disabled'} | ${true} + ${' '} | ${' '} | ${'disabled'} | ${true} `( "when title='$title', content='$content', then the button is $buttonState'", async ({ title, content, disabledAttr }) => { createWrapper(); - findTitle().setValue(title); - findContent().setValue(content); + await findTitle().setValue(title); + await findContent().setValue(content); - await wrapper.vm.$nextTick(); - - expect(findSubmitButton().attributes().disabled).toBe(disabledAttr); + expect(findSubmitButton().props().disabled).toBe(disabledAttr); }, ); @@ -288,7 +258,7 @@ describe('WikiForm', () => { ${true} | ${'Save changes'} ${false} | ${'Create page'} `('when persisted=$persisted, label is set to $buttonLabel', ({ persisted, buttonLabel }) => { - createWrapper(persisted); + createWrapper({ persisted }); expect(findSubmitButton().text()).toBe(buttonLabel); }); @@ -302,7 +272,7 @@ describe('WikiForm', () => { `( 'when persisted=$persisted, redirects the user to appropriate path', ({ persisted, redirectLink }) => { - createWrapper(persisted); + createWrapper({ persisted }); expect(findCancelButton().attributes().href).toBe(redirectLink); }, @@ -311,7 +281,7 @@ describe('WikiForm', () => { describe('when wikiSwitchBetweenContentEditorRawMarkdown feature flag is not enabled', () => { beforeEach(() => { - createShallowWrapper(true, { + createWrapper({ glFeatures: { wikiSwitchBetweenContentEditorRawMarkdown: false }, }); }); @@ -323,7 +293,7 @@ describe('WikiForm', () => { describe('when wikiSwitchBetweenContentEditorRawMarkdown feature flag is enabled', () => { beforeEach(() => { - createShallowWrapper(true, { + createWrapper({ glFeatures: { wikiSwitchBetweenContentEditorRawMarkdown: true }, }); }); @@ -404,10 +374,6 @@ describe('WikiForm', () => { }); describe('wiki content editor', () => { - beforeEach(() => { - createWrapper(true); - }); - it.each` format | buttonExists ${'markdown'} | ${true} @@ -415,15 +381,17 @@ describe('WikiForm', () => { `( 'gl-alert containing "use new editor" button exists: $buttonExists if format is $format', async ({ format, buttonExists }) => { - setFormat(format); + createWrapper(); - await wrapper.vm.$nextTick(); + await setFormat(format); expect(findUseNewEditorButton().exists()).toBe(buttonExists); }, ); it('gl-alert containing "use new editor" button is dismissed on clicking dismiss button', async () => { + createWrapper(); + await findDismissContentEditorAlertButton().trigger('click'); expect(findUseNewEditorButton().exists()).toBe(false); @@ -442,22 +410,24 @@ describe('WikiForm', () => { ); }; - it('shows classic editor by default', assertOldEditorIsVisible); + it('shows classic editor by default', () => { + createWrapper({ persisted: true }); + + assertOldEditorIsVisible(); + }); describe('switch format to rdoc', () => { beforeEach(async () => { - setFormat('rdoc'); + createWrapper({ persisted: true }); - await wrapper.vm.$nextTick(); + await setFormat('rdoc'); }); it('continues to show the classic editor', assertOldEditorIsVisible); describe('switch format back to markdown', () => { beforeEach(async () => { - setFormat('rdoc'); - - await wrapper.vm.$nextTick(); + await setFormat('markdown'); }); it( @@ -469,6 +439,7 @@ describe('WikiForm', () => { describe('clicking "use new editor": editor fails to load', () => { beforeEach(async () => { + createWrapper({ mountFn: mount }); mock.onPost(/preview-markdown/).reply(400); await findUseNewEditorButton().trigger('click'); @@ -494,10 +465,12 @@ describe('WikiForm', () => { }); describe('clicking "use new editor": editor loads successfully', () => { - beforeEach(() => { + beforeEach(async () => { + createWrapper({ persisted: true, mountFn: mount }); + mock.onPost(/preview-markdown/).reply(200, { body: '<p>hello <strong>world</strong></p>' }); - findUseNewEditorButton().trigger('click'); + await findUseNewEditorButton().trigger('click'); }); it('shows a tip to send feedback', () => { @@ -542,46 +515,40 @@ describe('WikiForm', () => { }); it('unsets before unload warning on form submit', async () => { - triggerFormSubmit(); - - await nextTick(); + await triggerFormSubmit(); const e = dispatchBeforeUnload(); expect(e.preventDefault).not.toHaveBeenCalled(); }); - }); - it('triggers tracking events on form submit', async () => { - triggerFormSubmit(); + it('triggers tracking events on form submit', async () => { + await triggerFormSubmit(); - await wrapper.vm.$nextTick(); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, SAVED_USING_CONTENT_EDITOR_ACTION, { - label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, - }); + expect(trackingSpy).toHaveBeenCalledWith(undefined, SAVED_USING_CONTENT_EDITOR_ACTION, { + label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, + }); - expect(trackingSpy).toHaveBeenCalledWith(undefined, WIKI_FORMAT_UPDATED_ACTION, { - label: WIKI_FORMAT_LABEL, - extra: { - value: findFormat().element.value, - old_format: pageInfoPersisted.format, - project_path: pageInfoPersisted.path, - }, + expect(trackingSpy).toHaveBeenCalledWith(undefined, WIKI_FORMAT_UPDATED_ACTION, { + label: WIKI_FORMAT_LABEL, + extra: { + value: findFormat().element.value, + old_format: pageInfoPersisted.format, + project_path: pageInfoPersisted.path, + }, + }); }); - }); - - it('updates content from content editor on form submit', async () => { - // old value - expect(findContent().element.value).toBe(' My page content '); - // wait for content editor to load - await waitForPromises(); + it('updates content from content editor on form submit', async () => { + // old value + expect(findContent().element.value).toBe(' My page content '); - triggerFormSubmit(); + // wait for content editor to load + await waitForPromises(); - await wrapper.vm.$nextTick(); + await triggerFormSubmit(); - expect(findContent().element.value).toBe('hello **world**'); + expect(findContent().element.value).toBe('hello **world**'); + }); }); describe('clicking "switch to classic editor"', () => { diff --git a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js index cab4810cbf1..f15d5f334d6 100644 --- a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js +++ b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js @@ -17,19 +17,12 @@ describe('Pipeline Editor | Text editor component', () => { let editorReadyListener; let mockUse; let mockRegisterCiSchema; + let mockEditorInstance; + let editorInstanceDetail; const MockSourceEditor = { template: '<div/>', props: ['value', 'fileName'], - mounted() { - this.$emit(EDITOR_READY_EVENT); - }, - methods: { - getEditor: () => ({ - use: mockUse, - registerCiSchema: mockRegisterCiSchema, - }), - }, }; const createComponent = (glFeatures = {}, mountFn = shallowMount) => { @@ -58,6 +51,21 @@ describe('Pipeline Editor | Text editor component', () => { const findEditor = () => wrapper.findComponent(MockSourceEditor); + beforeEach(() => { + editorReadyListener = jest.fn(); + mockUse = jest.fn(); + mockRegisterCiSchema = jest.fn(); + mockEditorInstance = { + use: mockUse, + registerCiSchema: mockRegisterCiSchema, + }; + editorInstanceDetail = { + detail: { + instance: mockEditorInstance, + }, + }; + }); + afterEach(() => { wrapper.destroy(); @@ -67,10 +75,6 @@ describe('Pipeline Editor | Text editor component', () => { describe('template', () => { beforeEach(() => { - editorReadyListener = jest.fn(); - mockUse = jest.fn(); - mockRegisterCiSchema = jest.fn(); - createComponent(); }); @@ -87,7 +91,7 @@ describe('Pipeline Editor | Text editor component', () => { }); it('bubbles up events', () => { - findEditor().vm.$emit(EDITOR_READY_EVENT); + findEditor().vm.$emit(EDITOR_READY_EVENT, editorInstanceDetail); expect(editorReadyListener).toHaveBeenCalled(); }); @@ -97,11 +101,7 @@ describe('Pipeline Editor | Text editor component', () => { describe('when `schema_linting` feature flag is on', () => { beforeEach(() => { createComponent({ schemaLinting: true }); - // Since the editor will have already mounted, the event will have fired. - // To ensure we properly test this, we clear the mock and re-remit the event. - mockRegisterCiSchema.mockClear(); - mockUse.mockClear(); - findEditor().vm.$emit(EDITOR_READY_EVENT); + findEditor().vm.$emit(EDITOR_READY_EVENT, editorInstanceDetail); }); it('configures editor with syntax highlight', () => { @@ -113,7 +113,7 @@ describe('Pipeline Editor | Text editor component', () => { describe('when `schema_linting` feature flag is off', () => { beforeEach(() => { createComponent(); - findEditor().vm.$emit(EDITOR_READY_EVENT); + findEditor().vm.$emit(EDITOR_READY_EVENT, editorInstanceDetail); }); it('does not call the register CI schema function', () => { 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 fd8a100bb2c..570323826d1 100644 --- a/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js +++ b/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js @@ -1,40 +1,61 @@ +import VueApollo from 'vue-apollo'; import { GlIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; import { escape } from 'lodash'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { sprintf } from '~/locale'; import ValidationSegment, { i18n, } from '~/pipeline_editor/components/header/validation_segment.vue'; +import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.query.graphql'; import { CI_CONFIG_STATUS_INVALID, EDITOR_APP_STATUS_EMPTY, EDITOR_APP_STATUS_INVALID, EDITOR_APP_STATUS_LOADING, + EDITOR_APP_STATUS_LINT_UNAVAILABLE, EDITOR_APP_STATUS_VALID, } from '~/pipeline_editor/constants'; -import { mockYmlHelpPagePath, mergeUnwrappedCiConfig, mockCiYml } from '../../mock_data'; +import { + mergeUnwrappedCiConfig, + mockCiYml, + mockLintUnavailableHelpPagePath, + mockYmlHelpPagePath, +} from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); describe('Validation segment component', () => { let wrapper; - const createComponent = ({ props = {}, appStatus }) => { + const mockApollo = createMockApollo(); + + const createComponent = ({ props = {}, appStatus = EDITOR_APP_STATUS_INVALID }) => { + mockApollo.clients.defaultClient.cache.writeQuery({ + query: getAppStatus, + data: { + app: { + __typename: 'PipelineEditorApp', + status: appStatus, + }, + }, + }); + wrapper = extendedWrapper( shallowMount(ValidationSegment, { + localVue, + apolloProvider: mockApollo, provide: { ymlHelpPagePath: mockYmlHelpPagePath, + lintUnavailableHelpPagePath: mockLintUnavailableHelpPagePath, }, propsData: { ciConfig: mergeUnwrappedCiConfig(), ciFileContent: mockCiYml, ...props, }, - // Simulate graphQL client query result - data() { - return { - appStatus, - }; - }, }), ); }; @@ -92,6 +113,7 @@ describe('Validation segment component', () => { appStatus: EDITOR_APP_STATUS_INVALID, }); }); + it('has warning icon', () => { expect(findIcon().props('name')).toBe('warning-solid'); }); @@ -149,4 +171,28 @@ describe('Validation segment component', () => { }); }); }); + + describe('when the lint service is unavailable', () => { + beforeEach(() => { + createComponent({ + appStatus: EDITOR_APP_STATUS_LINT_UNAVAILABLE, + props: { + ciConfig: {}, + }, + }); + }); + + it('show a message that the service is unavailable', () => { + expect(findValidationMsg().text()).toBe(i18n.unavailableValidation); + }); + + it('shows the time-out icon', () => { + expect(findIcon().props('name')).toBe('time-out'); + }); + + it('shows the learn more link', () => { + expect(findLearnMoreLink().attributes('href')).toBe(mockLintUnavailableHelpPagePath); + expect(findLearnMoreLink().text()).toBe(i18n.learnMore); + }); + }); }); 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 3becf82ed6e..6206a0f6aed 100644 --- a/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js +++ b/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js @@ -75,34 +75,83 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => { expect(mockChildMounted).toHaveBeenCalledWith(mockContent1); }); - describe('showing the tab content depending on `isEmpty` and `isInvalid`', () => { + describe('alerts', () => { + describe('unavailable state', () => { + beforeEach(() => { + createWrapper({ props: { isUnavailable: true } }); + }); + + it('shows the invalid alert when the status is invalid', () => { + const alert = findAlert(); + + expect(alert.exists()).toBe(true); + expect(alert.text()).toContain(wrapper.vm.$options.i18n.unavailable); + }); + }); + + describe('invalid state', () => { + beforeEach(() => { + createWrapper({ props: { isInvalid: true } }); + }); + + it('shows the invalid alert when the status is invalid', () => { + const alert = findAlert(); + + expect(alert.exists()).toBe(true); + expect(alert.text()).toBe(wrapper.vm.$options.i18n.invalid); + }); + }); + + describe('empty state', () => { + const text = 'my custom alert message'; + + beforeEach(() => { + createWrapper({ + props: { isEmpty: true, emptyMessage: text }, + }); + }); + + it('displays an empty message', () => { + createWrapper({ + props: { isEmpty: true }, + }); + + const alert = findAlert(); + + expect(alert.exists()).toBe(true); + expect(alert.text()).toBe( + 'This tab will be usable when the CI/CD configuration file is populated with valid syntax.', + ); + }); + + it('can have a custom empty message', () => { + const alert = findAlert(); + + expect(alert.exists()).toBe(true); + expect(alert.text()).toBe(text); + }); + }); + }); + + describe('showing the tab content depending on `isEmpty`, `isUnavailable` 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'} + isEmpty | isUnavailable | isInvalid | showSlotComponent | text + ${undefined} | ${undefined} | ${undefined} | ${true} | ${'renders'} + ${false} | ${false} | ${false} | ${true} | ${'renders'} + ${undefined} | ${true} | ${true} | ${false} | ${'hides'} + ${true} | ${false} | ${false} | ${false} | ${'hides'} + ${false} | ${true} | ${false} | ${false} | ${'hides'} + ${false} | ${false} | ${true} | ${false} | ${'hides'} `( - '$text the slot component when isEmpty:$isEmpty and isInvalid:$isInvalid', - ({ isEmpty, isInvalid, showSlotComponent }) => { + '$text the slot component when isEmpty:$isEmpty, isUnavailable:$isUnavailable and isInvalid:$isInvalid', + ({ isEmpty, isUnavailable, isInvalid, showSlotComponent }) => { createWrapper({ - props: { isEmpty, isInvalid }, + props: { isEmpty, isUnavailable, 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', () => { diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index fc2cbdeda0a..f02f6870653 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -10,6 +10,7 @@ export const mockNewMergeRequestPath = '/-/merge_requests/new'; export const mockCommitSha = 'aabbccdd'; export const mockCommitNextSha = 'eeffgghh'; export const mockLintHelpPagePath = '/-/lint-help'; +export const mockLintUnavailableHelpPagePath = '/-/pipeline-editor/troubleshoot'; export const mockYmlHelpPagePath = '/-/yml-help'; export const mockCommitMessage = 'My commit message'; diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js index 09d7d4f7ca6..63eca253c48 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -5,10 +5,15 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { resolvers } from '~/pipeline_editor/graphql/resolvers'; import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue'; import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_editor_empty_state.vue'; import PipelineEditorMessages from '~/pipeline_editor/components/ui/pipeline_editor_messages.vue'; -import { COMMIT_SUCCESS, COMMIT_FAILURE, LOAD_FAILURE_UNKNOWN } from '~/pipeline_editor/constants'; +import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue'; +import ValidationSegment, { + i18n as validationSegmenti18n, +} from '~/pipeline_editor/components/header/validation_segment.vue'; +import { COMMIT_SUCCESS, COMMIT_FAILURE } from '~/pipeline_editor/constants'; import getBlobContent from '~/pipeline_editor/graphql/queries/blob_content.query.graphql'; import getCiConfigData from '~/pipeline_editor/graphql/queries/ci_config.query.graphql'; import getTemplate from '~/pipeline_editor/graphql/queries/get_starter_template.query.graphql'; @@ -61,11 +66,6 @@ describe('Pipeline editor app component', () => { wrapper = shallowMount(PipelineEditorApp, { provide: { ...mockProvide, ...provide }, stubs, - data() { - return { - commitSha: '', - }; - }, mocks: { $apollo: { queries: { @@ -90,17 +90,11 @@ describe('Pipeline editor app component', () => { [getLatestCommitShaQuery, mockLatestCommitShaQuery], [getPipelineQuery, mockPipelineQuery], ]; - mockApollo = createMockApollo(handlers); + + mockApollo = createMockApollo(handlers, resolvers); const options = { localVue, - data() { - return { - currentBranch: mockDefaultBranch, - lastCommitBranch: '', - appStatus: '', - }; - }, mocks: {}, apolloProvider: mockApollo, }; @@ -116,6 +110,7 @@ describe('Pipeline editor app component', () => { const findEmptyState = () => wrapper.findComponent(PipelineEditorEmptyState); const findEmptyStateButton = () => wrapper.findComponent(PipelineEditorEmptyState).findComponent(GlButton); + const findValidationSegment = () => wrapper.findComponent(ValidationSegment); beforeEach(() => { mockBlobContentData = jest.fn(); @@ -240,6 +235,26 @@ describe('Pipeline editor app component', () => { }); }); + describe('when the lint query returns a 500 error', () => { + beforeEach(async () => { + mockCiConfigData.mockRejectedValueOnce(new Error(500)); + await createComponentWithApollo({ + stubs: { PipelineEditorHome, PipelineEditorHeader, ValidationSegment }, + }); + }); + + it('shows that the lint service is down', () => { + expect(findValidationSegment().text()).toContain( + validationSegmenti18n.unavailableValidation, + ); + }); + + it('does not report an error or scroll to the top', () => { + expect(findAlert().exists()).toBe(false); + expect(window.scrollTo).not.toHaveBeenCalled(); + }); + }); + describe('when the user commits', () => { const updateFailureMessage = 'The GitLab CI configuration could not be updated.'; const updateSuccessMessage = 'Your changes have been successfully committed.'; @@ -411,94 +426,6 @@ describe('Pipeline editor app component', () => { }); }); - describe('when multiple errors occurs in a row', () => { - const updateFailureMessage = 'The GitLab CI configuration could not be updated.'; - const unknownFailureMessage = 'The CI configuration was not loaded, please try again.'; - const unknownReasons = ['Commit failed']; - const alertErrorMessage = `${updateFailureMessage} ${unknownReasons[0]}`; - - const emitError = (type = COMMIT_FAILURE, reasons = unknownReasons) => - findEditorHome().vm.$emit('showError', { - type, - reasons, - }); - - beforeEach(async () => { - mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); - mockCiConfigData.mockResolvedValue(mockCiConfigQueryResponse); - mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults); - - window.scrollTo = jest.fn(); - - await createComponentWithApollo({ stubs: { PipelineEditorMessages } }); - await emitError(); - }); - - it('shows an error message for the first error', () => { - expect(findAlert().text()).toMatchInterpolatedText(alertErrorMessage); - }); - - it('scrolls to the top of the page to bring attention to the error message', () => { - expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); - expect(window.scrollTo).toHaveBeenCalledTimes(1); - }); - - it('does not scroll to the top of the page if the same error occur multiple times in a row', async () => { - await emitError(); - - expect(window.scrollTo).toHaveBeenCalledTimes(1); - expect(findAlert().text()).toMatchInterpolatedText(alertErrorMessage); - }); - - it('scrolls to the top if the error is different', async () => { - await emitError(LOAD_FAILURE_UNKNOWN, []); - - expect(findAlert().text()).toMatchInterpolatedText(unknownFailureMessage); - expect(window.scrollTo).toHaveBeenCalledTimes(2); - }); - - describe('when a user dismiss the alert', () => { - beforeEach(async () => { - await findAlert().vm.$emit('dismiss'); - }); - - it('shows an error if the type is the same, but the reason is different', async () => { - const newReason = 'Something broke'; - - await emitError(COMMIT_FAILURE, [newReason]); - - expect(window.scrollTo).toHaveBeenCalledTimes(2); - expect(findAlert().text()).toMatchInterpolatedText(`${updateFailureMessage} ${newReason}`); - }); - - it('does not show an error or scroll if a new error with the same type occurs', async () => { - await emitError(); - - expect(window.scrollTo).toHaveBeenCalledTimes(1); - expect(findAlert().exists()).toBe(false); - }); - - it('it shows an error and scroll when a new type is emitted', async () => { - await emitError(LOAD_FAILURE_UNKNOWN, []); - - expect(window.scrollTo).toHaveBeenCalledTimes(2); - expect(findAlert().text()).toMatchInterpolatedText(unknownFailureMessage); - }); - - it('it shows an error and scroll if a previously shown type happen again', async () => { - await emitError(LOAD_FAILURE_UNKNOWN, []); - - expect(window.scrollTo).toHaveBeenCalledTimes(2); - expect(findAlert().text()).toMatchInterpolatedText(unknownFailureMessage); - - await emitError(); - - expect(window.scrollTo).toHaveBeenCalledTimes(3); - expect(findAlert().text()).toMatchInterpolatedText(alertErrorMessage); - }); - }); - }); - describe('when add_new_config_file query param is present', () => { const originalLocation = window.location.href; diff --git a/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap b/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap index 99de0d2a3ef..52461885342 100644 --- a/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap +++ b/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap @@ -13,6 +13,7 @@ Array [ "id": "6", "name": "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", "needs": Array [], + "previousStageJobsOrNeeds": Array [], "scheduledAt": null, "status": Object { "__typename": "DetailedStatus", @@ -53,6 +54,7 @@ Array [ "id": "11", "name": "build_b", "needs": Array [], + "previousStageJobsOrNeeds": Array [], "scheduledAt": null, "status": Object { "__typename": "DetailedStatus", @@ -93,6 +95,7 @@ Array [ "id": "16", "name": "build_c", "needs": Array [], + "previousStageJobsOrNeeds": Array [], "scheduledAt": null, "status": Object { "__typename": "DetailedStatus", @@ -133,6 +136,7 @@ Array [ "id": "21", "name": "build_d 1/3", "needs": Array [], + "previousStageJobsOrNeeds": Array [], "scheduledAt": null, "status": Object { "__typename": "DetailedStatus", @@ -157,6 +161,7 @@ Array [ "id": "24", "name": "build_d 2/3", "needs": Array [], + "previousStageJobsOrNeeds": Array [], "scheduledAt": null, "status": Object { "__typename": "DetailedStatus", @@ -181,6 +186,7 @@ Array [ "id": "27", "name": "build_d 3/3", "needs": Array [], + "previousStageJobsOrNeeds": Array [], "scheduledAt": null, "status": Object { "__typename": "DetailedStatus", @@ -221,6 +227,7 @@ Array [ "id": "59", "name": "test_c", "needs": Array [], + "previousStageJobsOrNeeds": Array [], "scheduledAt": null, "status": Object { "__typename": "DetailedStatus", @@ -267,6 +274,11 @@ Array [ "build_b", "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", ], + "previousStageJobsOrNeeds": Array [ + "build_c", + "build_b", + "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", + ], "scheduledAt": null, "status": Object { "__typename": "DetailedStatus", @@ -313,6 +325,13 @@ Array [ "build_b", "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", ], + "previousStageJobsOrNeeds": Array [ + "build_d 3/3", + "build_d 2/3", + "build_d 1/3", + "build_b", + "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", + ], "scheduledAt": null, "status": Object { "__typename": "DetailedStatus", @@ -343,6 +362,13 @@ Array [ "build_b", "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", ], + "previousStageJobsOrNeeds": Array [ + "build_d 3/3", + "build_d 2/3", + "build_d 1/3", + "build_b", + "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", + ], "scheduledAt": null, "status": Object { "__typename": "DetailedStatus", @@ -385,6 +411,9 @@ Array [ "needs": Array [ "build_b", ], + "previousStageJobsOrNeeds": Array [ + "build_b", + ], "scheduledAt": null, "status": Object { "__typename": "DetailedStatus", diff --git a/spec/frontend/pipelines/graph/mock_data.js b/spec/frontend/pipelines/graph/mock_data.js index dcbbde7bf36..41823bfdb9f 100644 --- a/spec/frontend/pipelines/graph/mock_data.js +++ b/spec/frontend/pipelines/graph/mock_data.js @@ -73,6 +73,10 @@ export const mockPipelineResponse = { __typename: 'CiBuildNeedConnection', nodes: [], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [], + }, }, ], }, @@ -118,6 +122,10 @@ export const mockPipelineResponse = { __typename: 'CiBuildNeedConnection', nodes: [], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [], + }, }, ], }, @@ -163,6 +171,10 @@ export const mockPipelineResponse = { __typename: 'CiBuildNeedConnection', nodes: [], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [], + }, }, ], }, @@ -208,6 +220,10 @@ export const mockPipelineResponse = { __typename: 'CiBuildNeedConnection', nodes: [], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [], + }, }, { __typename: 'CiJob', @@ -235,6 +251,10 @@ export const mockPipelineResponse = { __typename: 'CiBuildNeedConnection', nodes: [], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [], + }, }, { __typename: 'CiJob', @@ -262,6 +282,10 @@ export const mockPipelineResponse = { __typename: 'CiBuildNeedConnection', nodes: [], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [], + }, }, ], }, @@ -339,6 +363,27 @@ export const mockPipelineResponse = { }, ], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiBuildNeed', + id: '37', + name: 'build_c', + }, + { + __typename: 'CiBuildNeed', + id: '38', + name: 'build_b', + }, + { + __typename: 'CiBuildNeed', + id: '39', + name: + 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + }, + ], + }, }, ], }, @@ -411,6 +456,37 @@ export const mockPipelineResponse = { }, ], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiBuildNeed', + id: '45', + name: 'build_d 3/3', + }, + { + __typename: 'CiBuildNeed', + id: '46', + name: 'build_d 2/3', + }, + { + __typename: 'CiBuildNeed', + id: '47', + name: 'build_d 1/3', + }, + { + __typename: 'CiBuildNeed', + id: '48', + name: 'build_b', + }, + { + __typename: 'CiBuildNeed', + id: '49', + name: + 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + }, + ], + }, }, { __typename: 'CiJob', @@ -465,6 +541,37 @@ export const mockPipelineResponse = { }, ], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiBuildNeed', + id: '52', + name: 'build_d 3/3', + }, + { + __typename: 'CiBuildNeed', + id: '53', + name: 'build_d 2/3', + }, + { + __typename: 'CiBuildNeed', + id: '54', + name: 'build_d 1/3', + }, + { + __typename: 'CiBuildNeed', + id: '55', + name: 'build_b', + }, + { + __typename: 'CiBuildNeed', + id: '56', + name: + 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + }, + ], + }, }, ], }, @@ -503,6 +610,10 @@ export const mockPipelineResponse = { __typename: 'CiBuildNeedConnection', nodes: [], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [], + }, }, ], }, @@ -547,6 +658,16 @@ export const mockPipelineResponse = { }, ], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiBuildNeed', + id: '65', + name: 'build_b', + }, + ], + }, }, ], }, @@ -720,6 +841,10 @@ export const wrappedPipelineReturn = { __typename: 'CiBuildNeedConnection', nodes: [], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [], + }, status: { __typename: 'DetailedStatus', id: '84', diff --git a/spec/frontend/profile/account/components/update_username_spec.js b/spec/frontend/profile/account/components/update_username_spec.js index 42adefcd0bb..bda07af4feb 100644 --- a/spec/frontend/profile/account/components/update_username_spec.js +++ b/spec/frontend/profile/account/components/update_username_spec.js @@ -79,6 +79,8 @@ describe('UpdateUsername component', () => { beforeEach(async () => { createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ newUsername }); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/profile/add_ssh_key_validation_spec.js b/spec/frontend/profile/add_ssh_key_validation_spec.js index 1fec864599c..a6bcca0ccb3 100644 --- a/spec/frontend/profile/add_ssh_key_validation_spec.js +++ b/spec/frontend/profile/add_ssh_key_validation_spec.js @@ -3,18 +3,18 @@ import AddSshKeyValidation from '../../../app/assets/javascripts/profile/add_ssh describe('AddSshKeyValidation', () => { describe('submit', () => { it('returns true if isValid is true', () => { - const addSshKeyValidation = new AddSshKeyValidation({}); - jest.spyOn(AddSshKeyValidation, 'isPublicKey').mockReturnValue(true); + const addSshKeyValidation = new AddSshKeyValidation([], {}); + jest.spyOn(addSshKeyValidation, 'isPublicKey').mockReturnValue(true); - expect(addSshKeyValidation.submit()).toBeTruthy(); + expect(addSshKeyValidation.submit()).toBe(true); }); it('calls preventDefault and toggleWarning if isValid is false', () => { - const addSshKeyValidation = new AddSshKeyValidation({}); + const addSshKeyValidation = new AddSshKeyValidation([], {}); const event = { preventDefault: jest.fn(), }; - jest.spyOn(AddSshKeyValidation, 'isPublicKey').mockReturnValue(false); + jest.spyOn(addSshKeyValidation, 'isPublicKey').mockReturnValue(false); jest.spyOn(addSshKeyValidation, 'toggleWarning').mockImplementation(() => {}); addSshKeyValidation.submit(event); @@ -31,14 +31,15 @@ describe('AddSshKeyValidation', () => { warningElement.classList.add('hide'); const addSshKeyValidation = new AddSshKeyValidation( + [], {}, warningElement, originalSubmitElement, ); addSshKeyValidation.toggleWarning(true); - expect(warningElement.classList.contains('hide')).toBeFalsy(); - expect(originalSubmitElement.classList.contains('hide')).toBeTruthy(); + expect(warningElement.classList.contains('hide')).toBe(false); + expect(originalSubmitElement.classList.contains('hide')).toBe(true); }); it('hides warningElement and shows originalSubmitElement if isVisible is false', () => { @@ -47,25 +48,32 @@ describe('AddSshKeyValidation', () => { originalSubmitElement.classList.add('hide'); const addSshKeyValidation = new AddSshKeyValidation( + [], {}, warningElement, originalSubmitElement, ); addSshKeyValidation.toggleWarning(false); - expect(warningElement.classList.contains('hide')).toBeTruthy(); - expect(originalSubmitElement.classList.contains('hide')).toBeFalsy(); + expect(warningElement.classList.contains('hide')).toBe(true); + expect(originalSubmitElement.classList.contains('hide')).toBe(false); }); }); describe('isPublicKey', () => { - it('returns false if probably invalid public ssh key', () => { - expect(AddSshKeyValidation.isPublicKey('nope')).toBeFalsy(); + it('returns false if value begins with an algorithm name that is unsupported', () => { + const addSshKeyValidation = new AddSshKeyValidation(['ssh-rsa', 'ssh-algorithm'], {}); + + expect(addSshKeyValidation.isPublicKey('nope key')).toBe(false); + expect(addSshKeyValidation.isPublicKey('ssh- key')).toBe(false); + expect(addSshKeyValidation.isPublicKey('unsupported-ssh-rsa key')).toBe(false); }); - it('returns true if probably valid public ssh key', () => { - expect(AddSshKeyValidation.isPublicKey('ssh-')).toBeTruthy(); - expect(AddSshKeyValidation.isPublicKey('ecdsa-sha2-')).toBeTruthy(); + it('returns true if value begins with an algorithm name that is supported', () => { + const addSshKeyValidation = new AddSshKeyValidation(['ssh-rsa', 'ssh-algorithm'], {}); + + expect(addSshKeyValidation.isPublicKey('ssh-rsa key')).toBe(true); + expect(addSshKeyValidation.isPublicKey('ssh-algorithm key')).toBe(true); }); }); }); diff --git a/spec/frontend/project_select_combo_button_spec.js b/spec/frontend/project_select_combo_button_spec.js index 5cdc3d174a1..40e7d27edc8 100644 --- a/spec/frontend/project_select_combo_button_spec.js +++ b/spec/frontend/project_select_combo_button_spec.js @@ -28,7 +28,7 @@ describe('Project Select Combo Button', () => { loadFixtures(fixturePath); - testContext.newItemBtn = document.querySelector('.new-project-item-link'); + testContext.newItemBtn = document.querySelector('.js-new-project-item-link'); testContext.projectSelectInput = document.querySelector('.project-item-select'); }); @@ -120,7 +120,6 @@ describe('Project Select Combo Button', () => { const returnedVariants = testContext.method(); expect(returnedVariants.localStorageItemType).toBe('new-merge-request'); - expect(returnedVariants.defaultTextPrefix).toBe('New merge request'); expect(returnedVariants.presetTextSuffix).toBe('merge request'); }); @@ -131,7 +130,6 @@ describe('Project Select Combo Button', () => { const returnedVariants = testContext.method(); expect(returnedVariants.localStorageItemType).toBe('new-issue'); - expect(returnedVariants.defaultTextPrefix).toBe('New issue'); expect(returnedVariants.presetTextSuffix).toBe('issue'); }); }); diff --git a/spec/frontend/projects/commits/components/author_select_spec.js b/spec/frontend/projects/commits/components/author_select_spec.js index 60d36597fda..23b4cccd92c 100644 --- a/spec/frontend/projects/commits/components/author_select_spec.js +++ b/spec/frontend/projects/commits/components/author_select_spec.js @@ -65,6 +65,8 @@ describe('Author Select', () => { describe('user is searching via "filter by commit message"', () => { it('disables dropdown container', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ hasSearchParam: true }); return wrapper.vm.$nextTick().then(() => { @@ -73,6 +75,8 @@ describe('Author Select', () => { }); it('has correct tooltip message', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ hasSearchParam: true }); return wrapper.vm.$nextTick().then(() => { @@ -83,6 +87,8 @@ describe('Author Select', () => { }); it('disables dropdown', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ hasSearchParam: false }); return wrapper.vm.$nextTick().then(() => { @@ -103,6 +109,8 @@ describe('Author Select', () => { }); it('displays the current selected author', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentAuthor }); return wrapper.vm.$nextTick().then(() => { @@ -156,6 +164,8 @@ describe('Author Select', () => { isChecked: true, }; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentAuthor }); return wrapper.vm.$nextTick().then(() => { 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 38e13dc5462..eb80d57fb3c 100644 --- a/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js +++ b/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js @@ -101,6 +101,8 @@ describe('RevisionDropdown component', () => { const findGlDropdownItems = () => wrapper.findAll(GlDropdownItem); const findFirstGlDropdownItem = () => findGlDropdownItems().at(0); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ branches: ['some-branch'] }); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/project_find_file_spec.js b/spec/frontend/projects/project_find_file_spec.js index 106b41bcc02..9c1000039b1 100644 --- a/spec/frontend/project_find_file_spec.js +++ b/spec/frontend/projects/project_find_file_spec.js @@ -3,7 +3,7 @@ import $ from 'jquery'; import { TEST_HOST } from 'helpers/test_constants'; import { sanitize } from '~/lib/dompurify'; import axios from '~/lib/utils/axios_utils'; -import ProjectFindFile from '~/project_find_file'; +import ProjectFindFile from '~/projects/project_find_file'; jest.mock('~/lib/dompurify', () => ({ addHook: jest.fn(), diff --git a/spec/frontend/repository/components/blob_button_group_spec.js b/spec/frontend/repository/components/blob_button_group_spec.js index 9f9d574a8ed..d5b882bd715 100644 --- a/spec/frontend/repository/components/blob_button_group_spec.js +++ b/spec/frontend/repository/components/blob_button_group_spec.js @@ -1,6 +1,5 @@ import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import BlobButtonGroup from '~/repository/components/blob_button_group.vue'; import DeleteBlobModal from '~/repository/components/delete_blob_modal.vue'; import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; @@ -16,6 +15,7 @@ const DEFAULT_PROPS = { projectPath: 'some/project/path', isLocked: false, canLock: true, + showForkSuggestion: false, }; const DEFAULT_INJECT = { @@ -27,7 +27,7 @@ describe('BlobButtonGroup component', () => { let wrapper; const createComponent = (props = {}) => { - wrapper = shallowMount(BlobButtonGroup, { + wrapper = mountExtended(BlobButtonGroup, { propsData: { ...DEFAULT_PROPS, ...props, @@ -35,9 +35,6 @@ describe('BlobButtonGroup component', () => { provide: { ...DEFAULT_INJECT, }, - directives: { - GlModal: createMockDirective(), - }, }); }; @@ -47,7 +44,8 @@ describe('BlobButtonGroup component', () => { const findDeleteBlobModal = () => wrapper.findComponent(DeleteBlobModal); const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal); - const findReplaceButton = () => wrapper.find('[data-testid="replace"]'); + const findDeleteButton = () => wrapper.findByTestId('delete'); + const findReplaceButton = () => wrapper.findByTestId('replace'); it('renders component', () => { createComponent(); @@ -63,6 +61,8 @@ describe('BlobButtonGroup component', () => { describe('buttons', () => { beforeEach(() => { createComponent(); + jest.spyOn(findUploadBlobModal().vm, 'show'); + jest.spyOn(findDeleteBlobModal().vm, 'show'); }); it('renders both the replace and delete button', () => { @@ -75,10 +75,37 @@ describe('BlobButtonGroup component', () => { }); it('triggers the UploadBlobModal from the replace button', () => { - const { value } = getBinding(findReplaceButton().element, 'gl-modal'); - const modalId = findUploadBlobModal().props('modalId'); + findReplaceButton().trigger('click'); + + expect(findUploadBlobModal().vm.show).toHaveBeenCalled(); + }); + + it('triggers the DeleteBlobModal from the delete button', () => { + findDeleteButton().trigger('click'); + + expect(findDeleteBlobModal().vm.show).toHaveBeenCalled(); + }); + + describe('showForkSuggestion set to true', () => { + beforeEach(() => { + createComponent({ showForkSuggestion: true }); + jest.spyOn(findUploadBlobModal().vm, 'show'); + jest.spyOn(findDeleteBlobModal().vm, 'show'); + }); + + it('does not trigger the UploadBlobModal from the replace button', () => { + findReplaceButton().trigger('click'); + + expect(findUploadBlobModal().vm.show).not.toHaveBeenCalled(); + expect(wrapper.emitted().fork).toBeTruthy(); + }); + + it('does not trigger the DeleteBlobModal from the delete button', () => { + findDeleteButton().trigger('click'); - expect(modalId).toEqual(value); + expect(findDeleteBlobModal().vm.show).not.toHaveBeenCalled(); + expect(wrapper.emitted().fork).toBeTruthy(); + }); }); }); diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index 9e00a2d0408..d3b60ec3768 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -83,6 +83,8 @@ const createComponent = async (mockData = {}, mountFn = shallowMount) => { }), ); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ project, isBinary }); await waitForPromises(); @@ -336,35 +338,11 @@ describe('Blob content viewer component', () => { deletePath: webPath, canPushCode: pushCode, canLock: true, - isLocked: true, + isLocked: false, emptyRepo: empty, }); }); - it.each` - canPushCode | canDownloadCode | username | canLock - ${true} | ${true} | ${'root'} | ${true} - ${false} | ${true} | ${'root'} | ${false} - ${true} | ${false} | ${'root'} | ${false} - ${true} | ${true} | ${'peter'} | ${false} - `( - 'passes the correct lock states', - async ({ canPushCode, canDownloadCode, username, canLock }) => { - gon.current_username = username; - - await createComponent( - { - pushCode: canPushCode, - downloadCode: canDownloadCode, - empty, - }, - mount, - ); - - expect(findBlobButtonGroup().props('canLock')).toBe(canLock); - }, - ); - it('does not render if not logged in', async () => { isLoggedIn.mockReturnValueOnce(false); diff --git a/spec/frontend/repository/components/blob_controls_spec.js b/spec/frontend/repository/components/blob_controls_spec.js new file mode 100644 index 00000000000..03e389ea5cb --- /dev/null +++ b/spec/frontend/repository/components/blob_controls_spec.js @@ -0,0 +1,88 @@ +import { createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import { nextTick } from 'vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import BlobControls from '~/repository/components/blob_controls.vue'; +import blobControlsQuery from '~/repository/queries/blob_controls.query.graphql'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createRouter from '~/repository/router'; +import { updateElementsVisibility } from '~/repository/utils/dom'; +import { blobControlsDataMock, refMock } from '../mock_data'; + +jest.mock('~/repository/utils/dom'); + +let router; +let wrapper; +let mockResolver; + +const localVue = createLocalVue(); + +const createComponent = async () => { + localVue.use(VueApollo); + + const project = { ...blobControlsDataMock }; + const projectPath = 'some/project'; + + router = createRouter(projectPath, refMock); + + router.replace({ name: 'blobPath', params: { path: '/some/file.js' } }); + + mockResolver = jest.fn().mockResolvedValue({ data: { project } }); + + wrapper = shallowMountExtended(BlobControls, { + localVue, + router, + apolloProvider: createMockApollo([[blobControlsQuery, mockResolver]]), + propsData: { projectPath }, + mixins: [{ data: () => ({ ref: refMock }) }], + }); + + await waitForPromises(); +}; + +describe('Blob controls component', () => { + const findFindButton = () => wrapper.findByTestId('find'); + const findBlameButton = () => wrapper.findByTestId('blame'); + const findHistoryButton = () => wrapper.findByTestId('history'); + const findPermalinkButton = () => wrapper.findByTestId('permalink'); + + beforeEach(() => createComponent()); + + afterEach(() => wrapper.destroy()); + + it('renders a find button with the correct href', () => { + expect(findFindButton().attributes('href')).toBe('find/file.js'); + }); + + it('renders a blame button with the correct href', () => { + expect(findBlameButton().attributes('href')).toBe('blame/file.js'); + }); + + it('renders a history button with the correct href', () => { + expect(findHistoryButton().attributes('href')).toBe('history/file.js'); + }); + + it('renders a permalink button with the correct href', () => { + expect(findPermalinkButton().attributes('href')).toBe('permalink/file.js'); + }); + + it.each` + name | path + ${'blobPathDecoded'} | ${null} + ${'treePathDecoded'} | ${'myFile.js'} + `( + 'does not render any buttons if router name is $name and router path is $path', + async ({ name, path }) => { + router.replace({ name, params: { path } }); + + await nextTick(); + + expect(findFindButton().exists()).toBe(false); + expect(findBlameButton().exists()).toBe(false); + expect(findHistoryButton().exists()).toBe(false); + expect(findPermalinkButton().exists()).toBe(false); + expect(updateElementsVisibility).toHaveBeenCalledWith('.tree-controls', true); + }, + ); +}); diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js index eb957c635ac..ad2cbd70187 100644 --- a/spec/frontend/repository/components/breadcrumbs_spec.js +++ b/spec/frontend/repository/components/breadcrumbs_spec.js @@ -75,6 +75,8 @@ describe('Repository breadcrumbs component', () => { it('does not render add to tree dropdown when permissions are false', async () => { factory('/', { canCollaborate: false }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ userPermissions: { forkProject: false, createMergeRequestIn: false } }); await wrapper.vm.$nextTick(); @@ -100,6 +102,8 @@ describe('Repository breadcrumbs component', () => { it('renders add to tree dropdown when permissions are true', async () => { factory('/', { canCollaborate: true }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ userPermissions: { forkProject: true, createMergeRequestIn: true } }); await wrapper.vm.$nextTick(); @@ -117,6 +121,8 @@ describe('Repository breadcrumbs component', () => { }); it('renders the modal once loaded', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ $apollo: { queries: { userPermissions: { loading: false } } } }); await wrapper.vm.$nextTick(); @@ -139,6 +145,8 @@ describe('Repository breadcrumbs component', () => { }); it('renders the modal once loaded', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ $apollo: { queries: { userPermissions: { loading: false } } } }); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js index ebea7dde34a..fe05a981845 100644 --- a/spec/frontend/repository/components/last_commit_spec.js +++ b/spec/frontend/repository/components/last_commit_spec.js @@ -43,6 +43,8 @@ function factory(commit = createCommitData(), loading = false) { }, }, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ commit }); vm.vm.$apollo.queries.commit.loading = loading; } diff --git a/spec/frontend/repository/components/preview/index_spec.js b/spec/frontend/repository/components/preview/index_spec.js index 466eed52739..2490258a048 100644 --- a/spec/frontend/repository/components/preview/index_spec.js +++ b/spec/frontend/repository/components/preview/index_spec.js @@ -34,6 +34,8 @@ describe('Repository file preview component', () => { name: 'README.md', }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ readme: { html: '<div class="blob">test</div>' } }); return vm.vm.$nextTick(() => { @@ -47,6 +49,8 @@ describe('Repository file preview component', () => { name: 'README.md', }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ readme: { html: '<div class="blob">test</div>' } }); return vm.vm @@ -63,6 +67,8 @@ describe('Repository file preview component', () => { name: 'README.md', }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ loading: 1 }); return vm.vm.$nextTick(() => { diff --git a/spec/frontend/repository/components/table/index_spec.js b/spec/frontend/repository/components/table/index_spec.js index c8dddefc4f2..2cd88944f81 100644 --- a/spec/frontend/repository/components/table/index_spec.js +++ b/spec/frontend/repository/components/table/index_spec.js @@ -89,6 +89,8 @@ describe('Repository table component', () => { `('renders table caption for $ref in $path', ({ path, ref }) => { factory({ path }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ ref }); return vm.vm.$nextTick(() => { diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js index 7f59dbfe0d1..440baa72a3c 100644 --- a/spec/frontend/repository/components/table/row_spec.js +++ b/spec/frontend/repository/components/table/row_spec.js @@ -40,6 +40,8 @@ function factory(propsData = {}) { }, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ escapedRef: 'main' }); } diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js index 9c5d07eede3..00ad1fc05f6 100644 --- a/spec/frontend/repository/components/tree_content_spec.js +++ b/spec/frontend/repository/components/tree_content_spec.js @@ -46,6 +46,8 @@ describe('Repository table component', () => { it('renders file preview', async () => { factory('/'); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ entries: { blobs: [{ name: 'README.md' }] } }); await vm.vm.$nextTick(); @@ -134,6 +136,8 @@ describe('Repository table component', () => { it('is not rendered if less than 1000 files', async () => { factory('/'); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ fetchCounter: 5, clickedShowMore: false }); await vm.vm.$nextTick(); @@ -153,6 +157,8 @@ describe('Repository table component', () => { factory('/'); const blobs = new Array(totalBlobs).fill('fakeBlob'); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ entries: { blobs }, pagesLoaded }); await vm.vm.$nextTick(); @@ -173,6 +179,8 @@ describe('Repository table component', () => { ${200} | ${100} `('exponentially increases page size, to a maximum of 100', ({ fetchCounter, pageSize }) => { factory('/'); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ fetchCounter }); vm.vm.fetchFiles(); diff --git a/spec/frontend/repository/components/upload_blob_modal_spec.js b/spec/frontend/repository/components/upload_blob_modal_spec.js index e9dfa3cd495..6b8b0752485 100644 --- a/spec/frontend/repository/components/upload_blob_modal_spec.js +++ b/spec/frontend/repository/components/upload_blob_modal_spec.js @@ -109,6 +109,8 @@ describe('UploadBlobModal', () => { if (canPushCode) { describe('when changing the branch name', () => { it('displays the MR toggle', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ target: 'Not main' }); await wrapper.vm.$nextTick(); @@ -120,6 +122,8 @@ describe('UploadBlobModal', () => { describe('completed form', () => { beforeEach(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ file: { type: 'jpg' }, filePreviewURL: 'http://file.com?format=jpg', diff --git a/spec/frontend/repository/mock_data.js b/spec/frontend/repository/mock_data.js index 74d35daf578..a5ee17ba672 100644 --- a/spec/frontend/repository/mock_data.js +++ b/spec/frontend/repository/mock_data.js @@ -13,7 +13,9 @@ export const simpleViewerMock = { ideForkAndEditPath: 'some_file.js/fork/ide', canModifyBlob: true, canCurrentUserPushToBranch: true, + archived: false, storedExternally: false, + externalStorage: 'lfs', rawPath: 'some_file.js', replacePath: 'some_file.js/replace', pipelineEditorPath: '', @@ -50,7 +52,7 @@ export const projectMock = { nodes: [ { id: 'test', - path: simpleViewerMock.path, + path: 'locked_file.js', user: { id: '123', username: 'root' }, }, ], @@ -63,3 +65,22 @@ export const projectMock = { export const propsMock = { path: 'some_file.js', projectPath: 'some/path' }; export const refMock = 'default-ref'; + +export const blobControlsDataMock = { + id: '1234', + repository: { + blobs: { + nodes: [ + { + id: '5678', + findFilePath: 'find/file.js', + blamePath: 'blame/file.js', + historyPath: 'history/file.js', + permalinkPath: 'permalink/file.js', + storedExternally: false, + externalStorage: '', + }, + ], + }, + }, +}; diff --git a/spec/frontend/runner/runner_detail/runner_details_app_spec.js b/spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js index 1a1428e8cb1..ad0bce5c9af 100644 --- a/spec/frontend/runner/runner_detail/runner_details_app_spec.js +++ b/spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js @@ -2,12 +2,12 @@ import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import RunnerTypeBadge from '~/runner/components/runner_type_badge.vue'; +import RunnerHeader from '~/runner/components/runner_header.vue'; import getRunnerQuery from '~/runner/graphql/get_runner.query.graphql'; -import RunnerDetailsApp from '~/runner/runner_details/runner_details_app.vue'; +import AdminRunnerEditApp from '~//runner/admin_runner_edit/admin_runner_edit_app.vue'; import { captureException } from '~/runner/sentry_utils'; import { runnerData } from '../mock_data'; @@ -21,14 +21,14 @@ const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`; const localVue = createLocalVue(); localVue.use(VueApollo); -describe('RunnerDetailsApp', () => { +describe('AdminRunnerEditApp', () => { let wrapper; let mockRunnerQuery; - const findRunnerTypeBadge = () => wrapper.findComponent(RunnerTypeBadge); + const findRunnerHeader = () => wrapper.findComponent(RunnerHeader); const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => { - wrapper = mountFn(RunnerDetailsApp, { + wrapper = mountFn(AdminRunnerEditApp, { localVue, apolloProvider: createMockApollo([[getRunnerQuery, mockRunnerQuery]]), propsData: { @@ -40,7 +40,7 @@ describe('RunnerDetailsApp', () => { return waitForPromises(); }; - beforeEach(async () => { + beforeEach(() => { mockRunnerQuery = jest.fn().mockResolvedValue(runnerData); }); @@ -56,15 +56,16 @@ describe('RunnerDetailsApp', () => { }); it('displays the runner id', async () => { - await createComponentWithApollo(); + await createComponentWithApollo({ mountFn: mount }); - expect(wrapper.text()).toContain(`Runner #${mockRunnerId}`); + expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId} created`); }); - it('displays the runner type', async () => { + it('displays the runner type and status', async () => { await createComponentWithApollo({ mountFn: mount }); - expect(findRunnerTypeBadge().text()).toBe('shared'); + expect(findRunnerHeader().text()).toContain(`never contacted`); + expect(findRunnerHeader().text()).toContain(`shared`); }); describe('When there is an error', () => { @@ -73,15 +74,15 @@ describe('RunnerDetailsApp', () => { await createComponentWithApollo(); }); - it('error is reported to sentry', async () => { + it('error is reported to sentry', () => { expect(captureException).toHaveBeenCalledWith({ error: new Error('Network error: Error!'), - component: 'RunnerDetailsApp', + component: 'AdminRunnerEditApp', }); }); - it('error is shown to the user', async () => { - expect(createFlash).toHaveBeenCalled(); + it('error is shown to the user', () => { + expect(createAlert).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js index 7015fe809b0..42be691ba4c 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -5,7 +5,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory } from '~/lib/utils/url_utility'; @@ -13,6 +13,7 @@ import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import RunnerList from '~/runner/components/runner_list.vue'; +import RunnerStats from '~/runner/components/stat/runner_stats.vue'; import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; import RunnerPagination from '~/runner/components/runner_pagination.vue'; @@ -22,23 +23,21 @@ import { CREATED_DESC, DEFAULT_SORT, INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, PARAM_KEY_STATUS, PARAM_KEY_TAG, STATUS_ACTIVE, RUNNER_PAGE_SIZE, } from '~/runner/constants'; import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; +import getRunnersCountQuery from '~/runner/graphql/get_runners_count.query.graphql'; import { captureException } from '~/runner/sentry_utils'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import { runnersData, runnersDataPaginated } from '../mock_data'; +import { runnersData, runnersCountData, runnersDataPaginated } from '../mock_data'; const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; -const mockActiveRunnersCount = '2'; -const mockAllRunnersCount = '6'; -const mockInstanceRunnersCount = '3'; -const mockGroupRunnersCount = '2'; -const mockProjectRunnersCount = '1'; jest.mock('~/flash'); jest.mock('~/runner/sentry_utils'); @@ -53,7 +52,9 @@ localVue.use(VueApollo); describe('AdminRunnersApp', () => { let wrapper; let mockRunnersQuery; + let mockRunnersCountQuery; + const findRunnerStats = () => wrapper.findComponent(RunnerStats); const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); const findRunnerList = () => wrapper.findComponent(RunnerList); @@ -65,27 +66,28 @@ describe('AdminRunnersApp', () => { const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { - const handlers = [[getRunnersQuery, mockRunnersQuery]]; - - wrapper = mountFn(AdminRunnersApp, { - localVue, - apolloProvider: createMockApollo(handlers), - propsData: { - registrationToken: mockRegistrationToken, - activeRunnersCount: mockActiveRunnersCount, - allRunnersCount: mockAllRunnersCount, - instanceRunnersCount: mockInstanceRunnersCount, - groupRunnersCount: mockGroupRunnersCount, - projectRunnersCount: mockProjectRunnersCount, - ...props, - }, - }); + const handlers = [ + [getRunnersQuery, mockRunnersQuery], + [getRunnersCountQuery, mockRunnersCountQuery], + ]; + + wrapper = extendedWrapper( + mountFn(AdminRunnersApp, { + localVue, + apolloProvider: createMockApollo(handlers), + propsData: { + registrationToken: mockRegistrationToken, + ...props, + }, + }), + ); }; beforeEach(async () => { setWindowLocation('/admin/runners'); mockRunnersQuery = jest.fn().mockResolvedValue(runnersData); + mockRunnersCountQuery = jest.fn().mockResolvedValue(runnersCountData); createComponent(); await waitForPromises(); }); @@ -95,13 +97,71 @@ describe('AdminRunnersApp', () => { wrapper.destroy(); }); - it('shows the runner tabs with a runner count', async () => { + it('shows total runner counts', async () => { createComponent({ mountFn: mount }); await waitForPromises(); + const stats = findRunnerStats().text(); + + expect(stats).toMatch('Online runners 4'); + expect(stats).toMatch('Offline runners 4'); + expect(stats).toMatch('Stale runners 4'); + }); + + it('shows the runner tabs with a runner count for each type', async () => { + mockRunnersCountQuery.mockImplementation(({ type }) => { + let count; + switch (type) { + case INSTANCE_TYPE: + count = 3; + break; + case GROUP_TYPE: + count = 2; + break; + case PROJECT_TYPE: + count = 1; + break; + default: + count = 6; + break; + } + return Promise.resolve({ data: { runners: { count } } }); + }); + + createComponent({ mountFn: mount }); + await waitForPromises(); + expect(findRunnerTypeTabs().text()).toMatchInterpolatedText( - `All ${mockAllRunnersCount} Instance ${mockInstanceRunnersCount} Group ${mockGroupRunnersCount} Project ${mockProjectRunnersCount}`, + `All 6 Instance 3 Group 2 Project 1`, + ); + }); + + it('shows the runner tabs with a formatted runner count', async () => { + mockRunnersCountQuery.mockImplementation(({ type }) => { + let count; + switch (type) { + case INSTANCE_TYPE: + count = 3000; + break; + case GROUP_TYPE: + count = 2000; + break; + case PROJECT_TYPE: + count = 1000; + break; + default: + count = 6000; + break; + } + return Promise.resolve({ data: { runners: { count } } }); + }); + + createComponent({ mountFn: mount }); + await waitForPromises(); + + expect(findRunnerTypeTabs().text()).toMatchInterpolatedText( + `All 6,000 Instance 3,000 Group 2,000 Project 1,000`, ); }); @@ -152,12 +212,6 @@ describe('AdminRunnersApp', () => { ]); }); - it('shows the active runner count', () => { - createComponent({ mountFn: mount }); - - expect(wrapper.text()).toMatch(new RegExp(`Online Runners ${mockActiveRunnersCount}`)); - }); - describe('when a filter is preselected', () => { beforeEach(async () => { setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`); @@ -241,7 +295,7 @@ describe('AdminRunnersApp', () => { }); it('error is shown to the user', async () => { - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledTimes(1); }); it('error is reported to sentry', async () => { diff --git a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js index 95c212cb0a9..4233d86c24c 100644 --- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js @@ -4,7 +4,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { captureException } from '~/runner/sentry_utils'; @@ -40,15 +40,17 @@ describe('RunnerTypeCell', () => { const findDeleteBtn = () => wrapper.findByTestId('delete-runner'); const getTooltip = (w) => getBinding(w.element, 'gl-tooltip')?.value; - const createComponent = ({ active = true } = {}, options) => { + const createComponent = (runner = {}, options) => { wrapper = extendedWrapper( shallowMount(RunnerActionCell, { propsData: { runner: { id: mockRunner.id, shortSha: mockRunner.shortSha, - adminUrl: mockRunner.adminUrl, - active, + editAdminUrl: mockRunner.editAdminUrl, + userPermissions: mockRunner.userPermissions, + active: mockRunner.active, + ...runner, }, }, localVue, @@ -101,7 +103,26 @@ describe('RunnerTypeCell', () => { it('Displays the runner edit link with the correct href', () => { createComponent(); - expect(findEditBtn().attributes('href')).toBe(mockRunner.adminUrl); + expect(findEditBtn().attributes('href')).toBe(mockRunner.editAdminUrl); + }); + + it('Does not render the runner edit link when user cannot update', () => { + createComponent({ + userPermissions: { + ...mockRunner.userPermissions, + updateRunner: false, + }, + }); + + expect(findEditBtn().exists()).toBe(false); + }); + + it('Does not render the runner edit link when editAdminUrl is not provided', () => { + createComponent({ + editAdminUrl: null, + }); + + expect(findEditBtn().exists()).toBe(false); }); }); @@ -179,7 +200,7 @@ describe('RunnerTypeCell', () => { }); it('error is shown to the user', () => { - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledTimes(1); }); }); @@ -208,11 +229,22 @@ describe('RunnerTypeCell', () => { }); it('error is shown to the user', () => { - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledTimes(1); }); }); }); }); + + it('Does not render the runner toggle active button when user cannot update', () => { + createComponent({ + userPermissions: { + ...mockRunner.userPermissions, + updateRunner: false, + }, + }); + + expect(findToggleActiveBtn().exists()).toBe(false); + }); }); describe('Delete action', () => { @@ -225,6 +257,10 @@ describe('RunnerTypeCell', () => { ); }); + it('Renders delete button', () => { + expect(findDeleteBtn().exists()).toBe(true); + }); + it('Delete button opens delete modal', () => { const modalId = getBinding(findDeleteBtn().element, 'gl-modal').value; @@ -259,6 +295,18 @@ describe('RunnerTypeCell', () => { }); }); + it('Does not render the runner delete button when user cannot delete', () => { + createComponent({ + userPermissions: { + ...mockRunner.userPermissions, + deleteRunner: false, + }, + }); + + expect(findDeleteBtn().exists()).toBe(false); + expect(findRunnerDeleteModal().exists()).toBe(false); + }); + describe('When delete is clicked', () => { beforeEach(() => { findRunnerDeleteModal().vm.$emit('primary'); @@ -302,7 +350,7 @@ describe('RunnerTypeCell', () => { }); it('error is shown to the user', () => { - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledTimes(1); }); it('toast notification is not shown', () => { @@ -334,7 +382,7 @@ describe('RunnerTypeCell', () => { }); it('error is shown to the user', () => { - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledTimes(1); }); }); }); diff --git a/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js b/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js index 0d002c272b4..e75decddf70 100644 --- a/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js +++ b/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js @@ -1,14 +1,15 @@ -import { GlDropdownItem, GlLoadingIcon, GlToast } from '@gitlab/ui'; +import { GlDropdownItem, GlLoadingIcon, GlToast, GlModal } 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 waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import RegistrationTokenResetDropdownItem from '~/runner/components/registration/registration_token_reset_dropdown_item.vue'; import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql'; import { captureException } from '~/runner/sentry_utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; jest.mock('~/flash'); jest.mock('~/runner/sentry_utils'); @@ -18,14 +19,18 @@ localVue.use(VueApollo); localVue.use(GlToast); const mockNewToken = 'NEW_TOKEN'; +const modalID = 'token-reset-modal'; describe('RegistrationTokenResetDropdownItem', () => { let wrapper; let runnersRegistrationTokenResetMutationHandler; let showToast; + const mockEvent = { preventDefault: jest.fn() }; const findDropdownItem = () => wrapper.findComponent(GlDropdownItem); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findModal = () => wrapper.findComponent(GlModal); + const clickSubmit = () => findModal().vm.$emit('primary', mockEvent); const createComponent = ({ props, provide = {} } = {}) => { wrapper = shallowMount(RegistrationTokenResetDropdownItem, { @@ -38,6 +43,9 @@ describe('RegistrationTokenResetDropdownItem', () => { apolloProvider: createMockApollo([ [runnersRegistrationTokenResetMutation, runnersRegistrationTokenResetMutationHandler], ]), + directives: { + GlModal: createMockDirective(), + }, }); showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null; @@ -54,8 +62,6 @@ describe('RegistrationTokenResetDropdownItem', () => { }); createComponent(); - - jest.spyOn(window, 'confirm'); }); afterEach(() => { @@ -66,6 +72,18 @@ describe('RegistrationTokenResetDropdownItem', () => { expect(findDropdownItem().exists()).toBe(true); }); + describe('modal directive integration', () => { + it('has the correct ID on the dropdown', () => { + const binding = getBinding(findDropdownItem().element, 'gl-modal'); + + expect(binding.value).toBe(modalID); + }); + + it('has the correct ID on the modal', () => { + expect(findModal().props('modalId')).toBe(modalID); + }); + }); + describe('On click and confirmation', () => { const mockGroupId = '11'; const mockProjectId = '22'; @@ -82,9 +100,8 @@ describe('RegistrationTokenResetDropdownItem', () => { props: { type }, }); - window.confirm.mockReturnValueOnce(true); - findDropdownItem().trigger('click'); + clickSubmit(); await waitForPromises(); }); @@ -114,7 +131,6 @@ describe('RegistrationTokenResetDropdownItem', () => { describe('On click without confirmation', () => { beforeEach(async () => { - window.confirm.mockReturnValueOnce(false); findDropdownItem().vm.$emit('click'); await waitForPromises(); }); @@ -142,11 +158,11 @@ describe('RegistrationTokenResetDropdownItem', () => { runnersRegistrationTokenResetMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); - window.confirm.mockReturnValueOnce(true); findDropdownItem().trigger('click'); + clickSubmit(); await waitForPromises(); - expect(createFlash).toHaveBeenLastCalledWith({ + expect(createAlert).toHaveBeenLastCalledWith({ message: `Network error: ${mockErrorMsg}`, }); expect(captureException).toHaveBeenCalledWith({ @@ -168,11 +184,11 @@ describe('RegistrationTokenResetDropdownItem', () => { }, }); - window.confirm.mockReturnValueOnce(true); findDropdownItem().trigger('click'); + clickSubmit(); await waitForPromises(); - expect(createFlash).toHaveBeenLastCalledWith({ + expect(createAlert).toHaveBeenLastCalledWith({ message: `${mockErrorMsg} ${mockErrorMsg2}`, }); expect(captureException).toHaveBeenCalledWith({ @@ -184,8 +200,8 @@ describe('RegistrationTokenResetDropdownItem', () => { describe('Immediately after click', () => { it('shows loading state', async () => { - window.confirm.mockReturnValue(true); findDropdownItem().trigger('click'); + clickSubmit(); await nextTick(); expect(findLoadingIcon().exists()).toBe(true); diff --git a/spec/frontend/runner/components/runner_header_spec.js b/spec/frontend/runner/components/runner_header_spec.js new file mode 100644 index 00000000000..50699df3a44 --- /dev/null +++ b/spec/frontend/runner/components/runner_header_spec.js @@ -0,0 +1,93 @@ +import { GlSprintf } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import { GROUP_TYPE, STATUS_ONLINE } from '~/runner/constants'; +import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; + +import RunnerHeader from '~/runner/components/runner_header.vue'; +import RunnerTypeBadge from '~/runner/components/runner_type_badge.vue'; +import RunnerStatusBadge from '~/runner/components/runner_status_badge.vue'; + +import { runnerData } from '../mock_data'; + +const mockRunner = runnerData.data.runner; + +describe('RunnerHeader', () => { + let wrapper; + + const findRunnerTypeBadge = () => wrapper.findComponent(RunnerTypeBadge); + const findRunnerStatusBadge = () => wrapper.findComponent(RunnerStatusBadge); + const findTimeAgo = () => wrapper.findComponent(TimeAgo); + + const createComponent = ({ runner = {}, mountFn = shallowMount } = {}) => { + wrapper = mountFn(RunnerHeader, { + propsData: { + runner: { + ...mockRunner, + ...runner, + }, + }, + stubs: { + GlSprintf, + TimeAgo, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays the runner status', () => { + createComponent({ + mountFn: mount, + runner: { + status: STATUS_ONLINE, + }, + }); + + expect(findRunnerStatusBadge().text()).toContain(`online`); + }); + + it('displays the runner type', () => { + createComponent({ + mountFn: mount, + runner: { + runnerType: GROUP_TYPE, + }, + }); + + expect(findRunnerTypeBadge().text()).toContain(`group`); + }); + + it('displays the runner id', () => { + createComponent({ + runner: { + id: convertToGraphQLId(TYPE_CI_RUNNER, 99), + }, + }); + + expect(wrapper.text()).toContain(`Runner #99`); + }); + + it('displays the runner creation time', () => { + createComponent(); + + expect(wrapper.text()).toMatch(/created .+/); + expect(findTimeAgo().props('time')).toBe(mockRunner.createdAt); + }); + + it('does not display runner creation time if createdAt missing', () => { + createComponent({ + runner: { + id: convertToGraphQLId(TYPE_CI_RUNNER, 99), + createdAt: null, + }, + }); + + expect(wrapper.text()).toContain(`Runner #99`); + expect(wrapper.text()).not.toMatch(/created .+/); + expect(findTimeAgo().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js index 5a14fa5a2d5..452430b7237 100644 --- a/spec/frontend/runner/components/runner_list_spec.js +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -69,7 +69,9 @@ describe('RunnerList', () => { const { id, description, version, ipAddress, shortSha } = mockRunners[0]; // Badges - expect(findCell({ fieldKey: 'status' }).text()).toMatchInterpolatedText('not connected paused'); + expect(findCell({ fieldKey: 'status' }).text()).toMatchInterpolatedText( + 'never contacted paused', + ); // Runner summary expect(findCell({ fieldKey: 'summary' }).text()).toContain( diff --git a/spec/frontend/runner/components/runner_status_badge_spec.js b/spec/frontend/runner/components/runner_status_badge_spec.js index a19515d6ed2..c470c6bb989 100644 --- a/spec/frontend/runner/components/runner_status_badge_spec.js +++ b/spec/frontend/runner/components/runner_status_badge_spec.js @@ -6,7 +6,6 @@ import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE, - STATUS_NOT_CONNECTED, STATUS_NEVER_CONTACTED, } from '~/runner/constants'; @@ -50,20 +49,7 @@ describe('RunnerTypeBadge', () => { expect(getTooltip().value).toBe('Runner is online; last contact was 1 minute ago'); }); - it('renders not connected state', () => { - createComponent({ - runner: { - contactedAt: null, - status: STATUS_NOT_CONNECTED, - }, - }); - - expect(wrapper.text()).toBe('not connected'); - expect(findBadge().props('variant')).toBe('muted'); - expect(getTooltip().value).toMatch('This runner has never connected'); - }); - - it('renders never contacted state as not connected, for backwards compatibility', () => { + it('renders never contacted state', () => { createComponent({ runner: { contactedAt: null, @@ -71,9 +57,9 @@ describe('RunnerTypeBadge', () => { }, }); - expect(wrapper.text()).toBe('not connected'); + expect(wrapper.text()).toBe('never contacted'); expect(findBadge().props('variant')).toBe('muted'); - expect(getTooltip().value).toMatch('This runner has never connected'); + expect(getTooltip().value).toMatch('This runner has never contacted'); }); it('renders offline state', () => { diff --git a/spec/frontend/runner/components/runner_type_alert_spec.js b/spec/frontend/runner/components/runner_type_alert_spec.js deleted file mode 100644 index 4023c75c9a8..00000000000 --- a/spec/frontend/runner/components/runner_type_alert_spec.js +++ /dev/null @@ -1,61 +0,0 @@ -import { GlAlert, GlLink } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import RunnerTypeAlert from '~/runner/components/runner_type_alert.vue'; -import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; - -describe('RunnerTypeAlert', () => { - let wrapper; - - const findAlert = () => wrapper.findComponent(GlAlert); - const findLink = () => wrapper.findComponent(GlLink); - - const createComponent = ({ props = {} } = {}) => { - wrapper = shallowMount(RunnerTypeAlert, { - propsData: { - type: INSTANCE_TYPE, - ...props, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - describe.each` - type | exampleText | anchor - ${INSTANCE_TYPE} | ${'This runner is available to all groups and projects'} | ${'#shared-runners'} - ${GROUP_TYPE} | ${'This runner is available to all projects and subgroups in a group'} | ${'#group-runners'} - ${PROJECT_TYPE} | ${'This runner is associated with one or more projects'} | ${'#specific-runners'} - `('When it is an $type level runner', ({ type, exampleText, anchor }) => { - beforeEach(() => { - createComponent({ props: { type } }); - }); - - it('Describes runner type', () => { - expect(wrapper.text()).toMatch(exampleText); - }); - - it(`Shows an "info" variant`, () => { - expect(findAlert().props('variant')).toBe('info'); - }); - - it(`Links to anchor "${anchor}"`, () => { - expect(findLink().attributes('href')).toBe(`/help/ci/runners/runners_scope${anchor}`); - }); - }); - - describe('When runner type is not correct', () => { - it('Does not render content when type is missing', () => { - createComponent({ props: { type: undefined } }); - - expect(wrapper.html()).toBe(''); - }); - - it('Validation fails for an incorrect type', () => { - expect(() => { - createComponent({ props: { type: 'NOT_A_TYPE' } }); - }).toThrow(); - }); - }); -}); diff --git a/spec/frontend/runner/components/runner_update_form_spec.js b/spec/frontend/runner/components/runner_update_form_spec.js index 0e0844a785b..ebb2e67d1e2 100644 --- a/spec/frontend/runner/components/runner_update_form_spec.js +++ b/spec/frontend/runner/components/runner_update_form_spec.js @@ -5,7 +5,7 @@ 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 createFlash, { FLASH_TYPES } from '~/flash'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; import RunnerUpdateForm from '~/runner/components/runner_update_form.vue'; import { INSTANCE_TYPE, @@ -79,9 +79,9 @@ describe('RunnerUpdateForm', () => { input: expect.objectContaining(submittedRunner), }); - expect(createFlash).toHaveBeenLastCalledWith({ + expect(createAlert).toHaveBeenLastCalledWith({ message: expect.stringContaining('saved'), - type: FLASH_TYPES.SUCCESS, + variant: VARIANT_SUCCESS, }); expect(findSubmitDisabledAttr()).toBeUndefined(); @@ -127,7 +127,7 @@ describe('RunnerUpdateForm', () => { await submitFormAndWait(); // Some fields are not submitted - const { ipAddress, runnerType, ...submitted } = mockRunner; + const { ipAddress, runnerType, createdAt, status, ...submitted } = mockRunner; expectToHaveSubmittedRunnerContaining(submitted); }); @@ -238,7 +238,7 @@ describe('RunnerUpdateForm', () => { await submitFormAndWait(); - expect(createFlash).toHaveBeenLastCalledWith({ + expect(createAlert).toHaveBeenLastCalledWith({ message: `Network error: ${mockErrorMsg}`, }); expect(captureException).toHaveBeenCalledWith({ @@ -262,7 +262,7 @@ describe('RunnerUpdateForm', () => { await submitFormAndWait(); - expect(createFlash).toHaveBeenLastCalledWith({ + expect(createAlert).toHaveBeenLastCalledWith({ message: mockErrorMsg, }); expect(captureException).not.toHaveBeenCalled(); diff --git a/spec/frontend/runner/components/search_tokens/tag_token_spec.js b/spec/frontend/runner/components/search_tokens/tag_token_spec.js index 89c06ba2df4..52557ff716d 100644 --- a/spec/frontend/runner/components/search_tokens/tag_token_spec.js +++ b/spec/frontend/runner/components/search_tokens/tag_token_spec.js @@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import TagToken, { TAG_SUGGESTIONS_PATH } from '~/runner/components/search_tokens/tag_token.vue'; @@ -168,8 +168,8 @@ describe('TagToken', () => { }); it('error is shown', async () => { - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ message: expect.any(String) }); + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ message: expect.any(String) }); }); }); diff --git a/spec/frontend/runner/components/stat/runner_online_stat_spec.js b/spec/frontend/runner/components/stat/runner_online_stat_spec.js deleted file mode 100644 index 18f865aa22c..00000000000 --- a/spec/frontend/runner/components/stat/runner_online_stat_spec.js +++ /dev/null @@ -1,34 +0,0 @@ -import { GlSingleStat } from '@gitlab/ui/dist/charts'; -import { shallowMount, mount } from '@vue/test-utils'; -import RunnerOnlineBadge from '~/runner/components/stat/runner_online_stat.vue'; - -describe('RunnerOnlineBadge', () => { - let wrapper; - - const findSingleStat = () => wrapper.findComponent(GlSingleStat); - - const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => { - wrapper = mountFn(RunnerOnlineBadge, { - propsData: { - value: '99', - ...props, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it('Uses a success appearance', () => { - createComponent({}, shallowMount); - - expect(findSingleStat().props('variant')).toBe('success'); - }); - - it('Renders a value', () => { - createComponent({}, mount); - - expect(wrapper.text()).toMatch(new RegExp(`Online Runners 99\\s+online`)); - }); -}); diff --git a/spec/frontend/runner/components/stat/runner_stats_spec.js b/spec/frontend/runner/components/stat/runner_stats_spec.js new file mode 100644 index 00000000000..68db8621ef0 --- /dev/null +++ b/spec/frontend/runner/components/stat/runner_stats_spec.js @@ -0,0 +1,46 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import RunnerStats from '~/runner/components/stat/runner_stats.vue'; +import RunnerStatusStat from '~/runner/components/stat/runner_status_stat.vue'; +import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '~/runner/constants'; + +describe('RunnerStats', () => { + let wrapper; + + const findRunnerStatusStatAt = (i) => wrapper.findAllComponents(RunnerStatusStat).at(i); + + const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { + wrapper = mountFn(RunnerStats, { + propsData: { + onlineRunnersCount: 3, + offlineRunnersCount: 2, + staleRunnersCount: 1, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays all the stats', () => { + createComponent({ mountFn: mount }); + + const stats = wrapper.text(); + + expect(stats).toMatch('Online runners 3'); + expect(stats).toMatch('Offline runners 2'); + expect(stats).toMatch('Stale runners 1'); + }); + + it.each` + i | status + ${0} | ${STATUS_ONLINE} + ${1} | ${STATUS_OFFLINE} + ${2} | ${STATUS_STALE} + `('Displays status types at index $i', ({ i, status }) => { + createComponent(); + + expect(findRunnerStatusStatAt(i).props('status')).toBe(status); + }); +}); diff --git a/spec/frontend/runner/components/stat/runner_status_stat_spec.js b/spec/frontend/runner/components/stat/runner_status_stat_spec.js new file mode 100644 index 00000000000..3218272eac7 --- /dev/null +++ b/spec/frontend/runner/components/stat/runner_status_stat_spec.js @@ -0,0 +1,67 @@ +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { shallowMount, mount } from '@vue/test-utils'; +import RunnerStatusStat from '~/runner/components/stat/runner_status_stat.vue'; +import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '~/runner/constants'; + +describe('RunnerStatusStat', () => { + let wrapper; + + const findSingleStat = () => wrapper.findComponent(GlSingleStat); + + const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => { + wrapper = mountFn(RunnerStatusStat, { + propsData: { + status: STATUS_ONLINE, + value: 99, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each` + status | variant | title | badge + ${STATUS_ONLINE} | ${'success'} | ${'Online runners'} | ${'online'} + ${STATUS_OFFLINE} | ${'muted'} | ${'Offline runners'} | ${'offline'} + ${STATUS_STALE} | ${'warning'} | ${'Stale runners'} | ${'stale'} + `('Renders a stat for status "$status"', ({ status, variant, title, badge }) => { + beforeEach(() => { + createComponent({ props: { status } }, mount); + }); + + it('Renders text', () => { + expect(wrapper.text()).toMatch(new RegExp(`${title} 99\\s+${badge}`)); + }); + + it(`Uses variant ${variant}`, () => { + expect(findSingleStat().props('variant')).toBe(variant); + }); + }); + + it('Formats stat number', () => { + createComponent({ props: { value: 1000 } }, mount); + + expect(wrapper.text()).toMatch('Online runners 1,000'); + }); + + it('Shows a null result', () => { + createComponent({ props: { value: null } }, mount); + + expect(wrapper.text()).toMatch('Online runners -'); + }); + + it('Shows an undefined result', () => { + createComponent({ props: { value: undefined } }, mount); + + expect(wrapper.text()).toMatch('Online runners -'); + }); + + it('Shows result for an unknown status', () => { + createComponent({ props: { status: 'UNKNOWN' } }, mount); + + expect(wrapper.text()).toMatch('Runners 99'); + }); +}); diff --git a/spec/frontend/runner/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js index 4451100de19..034b7848f35 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -6,12 +6,13 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory } from '~/lib/utils/url_utility'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import RunnerList from '~/runner/components/runner_list.vue'; +import RunnerStats from '~/runner/components/stat/runner_stats.vue'; import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; import RunnerPagination from '~/runner/components/runner_pagination.vue'; @@ -26,10 +27,11 @@ import { RUNNER_PAGE_SIZE, } from '~/runner/constants'; import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql'; +import getGroupRunnersCountQuery from '~/runner/graphql/get_group_runners_count.query.graphql'; import GroupRunnersApp from '~/runner/group_runners/group_runners_app.vue'; import { captureException } from '~/runner/sentry_utils'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import { groupRunnersData, groupRunnersDataPaginated } from '../mock_data'; +import { groupRunnersData, groupRunnersDataPaginated, groupRunnersCountData } from '../mock_data'; const localVue = createLocalVue(); localVue.use(VueApollo); @@ -48,7 +50,9 @@ jest.mock('~/lib/utils/url_utility', () => ({ describe('GroupRunnersApp', () => { let wrapper; let mockGroupRunnersQuery; + let mockGroupRunnersCountQuery; + const findRunnerStats = () => wrapper.findComponent(RunnerStats); const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); @@ -59,7 +63,10 @@ describe('GroupRunnersApp', () => { const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { - const handlers = [[getGroupRunnersQuery, mockGroupRunnersQuery]]; + const handlers = [ + [getGroupRunnersQuery, mockGroupRunnersQuery], + [getGroupRunnersCountQuery, mockGroupRunnersCountQuery], + ]; wrapper = mountFn(GroupRunnersApp, { localVue, @@ -77,11 +84,24 @@ describe('GroupRunnersApp', () => { setWindowLocation(`/groups/${mockGroupFullPath}/-/runners`); mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersData); + mockGroupRunnersCountQuery = jest.fn().mockResolvedValue(groupRunnersCountData); createComponent(); await waitForPromises(); }); + it('shows total runner counts', async () => { + createComponent({ mountFn: mount }); + + await waitForPromises(); + + const stats = findRunnerStats().text(); + + expect(stats).toMatch('Online runners 2'); + expect(stats).toMatch('Offline runners 2'); + expect(stats).toMatch('Stale runners 2'); + }); + it('shows the runner setup instructions', () => { expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken); expect(findRegistrationDropdown().props('type')).toBe(GROUP_TYPE); @@ -129,28 +149,6 @@ describe('GroupRunnersApp', () => { ); }); - describe('shows the active runner count', () => { - const expectedOnlineCount = (count) => new RegExp(`Online Runners ${count}`); - - it('with a regular value', () => { - createComponent({ mountFn: mount }); - - expect(wrapper.text()).toMatch(expectedOnlineCount(mockGroupRunnersLimitedCount)); - }); - - it('at the limit', () => { - createComponent({ props: { groupRunnersLimitedCount: 1000 }, mountFn: mount }); - - expect(wrapper.text()).toMatch(expectedOnlineCount('1,000')); - }); - - it('over the limit', () => { - createComponent({ props: { groupRunnersLimitedCount: 1001 }, mountFn: mount }); - - expect(wrapper.text()).toMatch(expectedOnlineCount('1,000\\+')); - }); - }); - describe('when a filter is preselected', () => { beforeEach(async () => { setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`); @@ -236,7 +234,7 @@ describe('GroupRunnersApp', () => { }); it('error is shown to the user', async () => { - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledTimes(1); }); it('error is reported to sentry', async () => { diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js index b8d0f1273c7..9c430e205ea 100644 --- a/spec/frontend/runner/mock_data.js +++ b/spec/frontend/runner/mock_data.js @@ -2,17 +2,21 @@ // Admin queries import runnersData from 'test_fixtures/graphql/runner/get_runners.query.graphql.json'; +import runnersCountData from 'test_fixtures/graphql/runner/get_runners_count.query.graphql.json'; import runnersDataPaginated from 'test_fixtures/graphql/runner/get_runners.query.graphql.paginated.json'; import runnerData from 'test_fixtures/graphql/runner/get_runner.query.graphql.json'; // Group queries import groupRunnersData from 'test_fixtures/graphql/runner/get_group_runners.query.graphql.json'; +import groupRunnersCountData from 'test_fixtures/graphql/runner/get_group_runners_count.query.graphql.json'; import groupRunnersDataPaginated from 'test_fixtures/graphql/runner/get_group_runners.query.graphql.paginated.json'; export { runnerData, + runnersCountData, runnersDataPaginated, runnersData, groupRunnersData, + groupRunnersCountData, groupRunnersDataPaginated, }; diff --git a/spec/frontend/runner/runner_search_utils_spec.js b/spec/frontend/runner/runner_search_utils_spec.js index 0fc7917663e..aff1ec882bb 100644 --- a/spec/frontend/runner/runner_search_utils_spec.js +++ b/spec/frontend/runner/runner_search_utils_spec.js @@ -1,6 +1,7 @@ import { RUNNER_PAGE_SIZE } from '~/runner/constants'; import { searchValidator, + updateOutdatedUrl, fromUrlQueryToSearch, fromSearchToUrl, fromSearchToVariables, @@ -190,6 +191,23 @@ describe('search_params.js', () => { }); }); + describe('updateOutdatedUrl', () => { + it('returns null for urls that do not need updating', () => { + expect(updateOutdatedUrl('http://test.host/')).toBe(null); + expect(updateOutdatedUrl('http://test.host/?a=b')).toBe(null); + }); + + it('returns updated url for updating NOT_CONNECTED to NEVER_CONTACTED', () => { + expect(updateOutdatedUrl('http://test.host/admin/runners?status[]=NOT_CONNECTED')).toBe( + 'http://test.host/admin/runners?status[]=NEVER_CONTACTED', + ); + + expect(updateOutdatedUrl('http://test.host/admin/runners?status[]=NOT_CONNECTED&a=b')).toBe( + 'http://test.host/admin/runners?status[]=NEVER_CONTACTED&a=b', + ); + }); + }); + describe('fromUrlQueryToSearch', () => { examples.forEach(({ name, urlQuery, search }) => { it(`Converts ${name} to a search object`, () => { diff --git a/spec/frontend/runner/runner_detail/runner_update_form_utils_spec.js b/spec/frontend/runner/runner_update_form_utils_spec.js index 510b4e604ac..a633aee92f7 100644 --- a/spec/frontend/runner/runner_detail/runner_update_form_utils_spec.js +++ b/spec/frontend/runner/runner_update_form_utils_spec.js @@ -1,8 +1,5 @@ import { ACCESS_LEVEL_NOT_PROTECTED } from '~/runner/constants'; -import { - modelToUpdateMutationVariables, - runnerToModel, -} from '~/runner/runner_details/runner_update_form_utils'; +import { modelToUpdateMutationVariables, runnerToModel } from '~/runner/runner_update_form_utils'; const mockId = 'gid://gitlab/Ci::Runner/1'; const mockDescription = 'Runner Desc.'; @@ -23,7 +20,7 @@ const mockModel = { tagList: 'tag-1, tag-2', }; -describe('~/runner/runner_details/runner_update_form_utils', () => { +describe('~/runner/runner_update_form_utils', () => { describe('runnerToModel', () => { it('collects all model data', () => { expect(runnerToModel(mockRunner)).toEqual(mockModel); diff --git a/spec/frontend/search/topbar/components/searchable_dropdown_spec.js b/spec/frontend/search/topbar/components/searchable_dropdown_spec.js index b21cf5c6b79..de1cefa9e9d 100644 --- a/spec/frontend/search/topbar/components/searchable_dropdown_spec.js +++ b/spec/frontend/search/topbar/components/searchable_dropdown_spec.js @@ -133,6 +133,8 @@ describe('Global Search Searchable Dropdown', () => { describe(`when search is ${searchText} and frequentItems length is ${frequentItems.length}`, () => { beforeEach(() => { createComponent({}, { frequentItems }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchText }); }); @@ -202,6 +204,8 @@ describe('Global Search Searchable Dropdown', () => { describe('not for the first time', () => { beforeEach(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ hasBeenOpened: true }); findGlDropdown().vm.$emit('show'); }); diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js index 0a2b18caf25..cbdf7f53913 100644 --- a/spec/frontend/security_configuration/components/app_spec.js +++ b/spec/frontend/security_configuration/components/app_spec.js @@ -1,4 +1,4 @@ -import { GlTab } from '@gitlab/ui'; +import { GlTab, GlTabs } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; @@ -77,6 +77,7 @@ describe('App component', () => { const findMainHeading = () => wrapper.find('h1'); const findTab = () => wrapper.findComponent(GlTab); const findTabs = () => wrapper.findAllComponents(GlTab); + const findGlTabs = () => wrapper.findComponent(GlTabs); const findByTestId = (id) => wrapper.findByTestId(id); const findFeatureCards = () => wrapper.findAllComponents(FeatureCard); const findTrainingProviderList = () => wrapper.findComponent(TrainingProviderList); @@ -154,6 +155,14 @@ describe('App component', () => { expect(findTab().exists()).toBe(true); }); + it('passes the `sync-active-tab-with-query-params` prop', () => { + expect(findGlTabs().props('syncActiveTabWithQueryParams')).toBe(true); + }); + + it('lazy loads each tab', () => { + expect(findGlTabs().attributes('lazy')).not.toBe(undefined); + }); + it('renders correct amount of tabs', () => { expect(findTabs()).toHaveLength(expectedTabs.length); }); @@ -161,6 +170,10 @@ describe('App component', () => { it.each(expectedTabs)('renders the %s tab', (tabName) => { expect(findByTestId(`${tabName}-tab`).exists()).toBe(true); }); + + it.each(expectedTabs)('has the %s query-param-value', (tabName) => { + expect(findByTestId(`${tabName}-tab`).props('queryParamValue')).toBe(tabName); + }); }); it('renders right amount of feature cards for given props with correct props', () => { @@ -182,10 +195,6 @@ describe('App component', () => { expect(findComplianceViewHistoryLink().exists()).toBe(false); expect(findSecurityViewHistoryLink().exists()).toBe(false); }); - - it('renders TrainingProviderList component', () => { - expect(findTrainingProviderList().exists()).toBe(true); - }); }); describe('Manage via MR Error Alert', () => { @@ -432,6 +441,25 @@ describe('App component', () => { }); }); + describe('Vulnerability management', () => { + beforeEach(() => { + createComponent({ + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock, + }); + }); + + it('renders TrainingProviderList component', () => { + expect(findTrainingProviderList().exists()).toBe(true); + }); + + it('renders security training description', () => { + const vulnerabilityManagementTab = wrapper.findByTestId('vulnerability-management-tab'); + + expect(vulnerabilityManagementTab.text()).toContain(i18n.securityTrainingDescription); + }); + }); + describe('when secureVulnerabilityTraining feature flag is disabled', () => { beforeEach(() => { createComponent({ diff --git a/spec/frontend/security_configuration/components/training_provider_list_spec.js b/spec/frontend/security_configuration/components/training_provider_list_spec.js index 60cc36a634c..578248e696f 100644 --- a/spec/frontend/security_configuration/components/training_provider_list_spec.js +++ b/spec/frontend/security_configuration/components/training_provider_list_spec.js @@ -1,87 +1,192 @@ -import { GlLink, GlToggle, GlCard, GlSkeletonLoader } from '@gitlab/ui'; +import { GlAlert, GlLink, GlToggle, GlCard, GlSkeletonLoader } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue'; +import configureSecurityTrainingProvidersMutation from '~/security_configuration/graphql/configure_security_training_providers.mutation.graphql'; import waitForPromises from 'helpers/wait_for_promises'; -import { securityTrainingProviders, mockResolvers } from '../mock_data'; +import { + securityTrainingProviders, + createMockResolvers, + testProjectPath, + textProviderIds, +} from '../mock_data'; Vue.use(VueApollo); describe('TrainingProviderList component', () => { let wrapper; - let mockApollo; - let mockSecurityTrainingProvidersData; + let apolloProvider; - const createComponent = () => { - mockApollo = createMockApollo([], mockResolvers); + const createApolloProvider = ({ resolvers } = {}) => { + apolloProvider = createMockApollo([], createMockResolvers({ resolvers })); + }; + const createComponent = () => { wrapper = shallowMount(TrainingProviderList, { - apolloProvider: mockApollo, + provide: { + projectPath: testProjectPath, + }, + apolloProvider, }); }; const waitForQueryToBeLoaded = () => waitForPromises(); + const waitForMutationToBeLoaded = waitForQueryToBeLoaded; const findCards = () => wrapper.findAllComponents(GlCard); const findLinks = () => wrapper.findAllComponents(GlLink); const findToggles = () => wrapper.findAllComponents(GlToggle); + const findFirstToggle = () => findToggles().at(0); const findLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findErrorAlert = () => wrapper.findComponent(GlAlert); - beforeEach(() => { - mockSecurityTrainingProvidersData = jest.fn(); - mockSecurityTrainingProvidersData.mockResolvedValue(securityTrainingProviders); - - createComponent(); - }); + const toggleFirstProvider = () => findFirstToggle().vm.$emit('change'); afterEach(() => { wrapper.destroy(); - mockApollo = null; + apolloProvider = null; }); - describe('when loading', () => { - it('shows the loader', () => { - expect(findLoader().exists()).toBe(true); + describe('with a successful response', () => { + beforeEach(() => { + createApolloProvider(); + createComponent(); }); - it('does not show the cards', () => { - expect(findCards().exists()).toBe(false); + describe('when loading', () => { + it('shows the loader', () => { + expect(findLoader().exists()).toBe(true); + }); + + it('does not show the cards', () => { + expect(findCards().exists()).toBe(false); + }); }); - }); - describe('basic structure', () => { - beforeEach(async () => { - await waitForQueryToBeLoaded(); + describe('basic structure', () => { + beforeEach(async () => { + await waitForQueryToBeLoaded(); + }); + + it('renders correct amount of cards', () => { + expect(findCards()).toHaveLength(securityTrainingProviders.length); + }); + + securityTrainingProviders.forEach(({ name, description, url, isEnabled }, index) => { + it(`shows the name for card ${index}`, () => { + expect(findCards().at(index).text()).toContain(name); + }); + + it(`shows the description for card ${index}`, () => { + expect(findCards().at(index).text()).toContain(description); + }); + + it(`shows the learn more link for card ${index}`, () => { + expect(findLinks().at(index).attributes()).toEqual({ + target: '_blank', + href: url, + }); + }); + + it(`shows the toggle with the correct value for card ${index}`, () => { + expect(findToggles().at(index).props('value')).toEqual(isEnabled); + }); + + it('does not show loader when query is populated', () => { + expect(findLoader().exists()).toBe(false); + }); + }); }); - it('renders correct amount of cards', () => { - expect(findCards()).toHaveLength(securityTrainingProviders.length); + describe('storing training provider settings', () => { + beforeEach(async () => { + jest.spyOn(apolloProvider.defaultClient, 'mutate'); + + await waitForMutationToBeLoaded(); + + toggleFirstProvider(); + }); + + it.each` + loading | wait | desc + ${true} | ${false} | ${'enables loading of GlToggle when mutation is called'} + ${false} | ${true} | ${'disables loading of GlToggle when mutation is complete'} + `('$desc', async ({ loading, wait }) => { + if (wait) { + await waitForMutationToBeLoaded(); + } + expect(findFirstToggle().props('isLoading')).toBe(loading); + }); + + it('calls mutation when toggle is changed', () => { + expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + mutation: configureSecurityTrainingProvidersMutation, + variables: { input: { enabledProviders: textProviderIds, fullPath: testProjectPath } }, + }), + ); + }); }); + }); + + describe('with errors', () => { + const expectErrorAlertToExist = () => { + expect(findErrorAlert().props()).toMatchObject({ + dismissible: false, + variant: 'danger', + }); + }; + + describe('when fetching training providers', () => { + beforeEach(async () => { + createApolloProvider({ + resolvers: { + Query: { + securityTrainingProviders: jest.fn().mockReturnValue(new Error()), + }, + }, + }); + createComponent(); - securityTrainingProviders.forEach(({ name, description, url, isEnabled }, index) => { - it(`shows the name for card ${index}`, () => { - expect(findCards().at(index).text()).toContain(name); + await waitForQueryToBeLoaded(); }); - it(`shows the description for card ${index}`, () => { - expect(findCards().at(index).text()).toContain(description); + it('shows an non-dismissible error alert', () => { + expectErrorAlertToExist(); }); - it(`shows the learn more link for card ${index}`, () => { - expect(findLinks().at(index).attributes()).toEqual({ - target: '_blank', - href: url, + it('shows an error description', () => { + expect(findErrorAlert().text()).toBe(TrainingProviderList.i18n.providerQueryErrorMessage); + }); + }); + + describe('when storing training provider configurations', () => { + beforeEach(async () => { + createApolloProvider({ + resolvers: { + Mutation: { + configureSecurityTrainingProviders: () => ({ + errors: ['something went wrong!'], + securityTrainingProviders: [], + }), + }, + }, }); + createComponent(); + + await waitForQueryToBeLoaded(); + toggleFirstProvider(); + await waitForMutationToBeLoaded(); }); - it(`shows the toggle with the correct value for card ${index}`, () => { - expect(findToggles().at(index).props('value')).toEqual(isEnabled); + it('shows an non-dismissible error alert', () => { + expectErrorAlertToExist(); }); - it('does not show loader when query is populated', () => { - expect(findLoader().exists()).toBe(false); + it('shows an error description', () => { + expect(findErrorAlert().text()).toBe(TrainingProviderList.i18n.configMutationErrorMessage); }); }); }); diff --git a/spec/frontend/security_configuration/mock_data.js b/spec/frontend/security_configuration/mock_data.js index cdb859c3800..37ecce3886d 100644 --- a/spec/frontend/security_configuration/mock_data.js +++ b/spec/frontend/security_configuration/mock_data.js @@ -1,16 +1,20 @@ +export const testProjectPath = 'foo/bar'; + +export const textProviderIds = [101, 102]; + export const securityTrainingProviders = [ { - id: 101, - name: 'Kontra', - description: 'Interactive developer security education.', - url: 'https://application.security/', + id: textProviderIds[0], + name: 'Vendor Name 1', + description: 'Interactive developer security education', + url: 'https://www.example.org/security/training', isEnabled: false, }, { - id: 102, - name: 'SecureCodeWarrior', + id: textProviderIds[1], + name: 'Vendor Name 2', description: 'Security training with guide and learning pathways.', - url: 'https://www.securecodewarrior.com/', + url: 'https://www.vendornametwo.com/', isEnabled: true, }, ]; @@ -21,10 +25,15 @@ export const securityTrainingProvidersResponse = { }, }; -export const mockResolvers = { +const defaultMockResolvers = { Query: { securityTrainingProviders() { return securityTrainingProviders; }, }, }; + +export const createMockResolvers = ({ resolvers: customMockResolvers = {} } = {}) => ({ + ...defaultMockResolvers, + ...customMockResolvers, +}); 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 c25a8d4bb92..350055cb935 100644 --- a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap +++ b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap @@ -1,12 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`EmptyStateComponent should render content 1`] = ` -"<section class=\\"row empty-state text-center\\"> - <div class=\\"col-12\\"> +"<section class=\\"gl-display-flex empty-state gl-text-center gl-flex-direction-column\\"> + <div class=\\"gl-max-w-full\\"> <div class=\\"svg-250 svg-content\\"><img src=\\"/image.svg\\" alt=\\"\\" role=\\"img\\" class=\\"gl-max-w-full\\"></div> </div> - <div class=\\"col-12\\"> - <div class=\\"text-content gl-mx-auto gl-my-0 gl-p-5\\"> + <div class=\\"gl-max-w-full gl-m-auto\\"> + <div class=\\"gl-mx-auto gl-my-0 gl-p-5\\"> <h1 class=\\"gl-font-size-h-display gl-line-height-36 h4\\"> Getting started with serverless </h1> 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 d7261784edc..0c6ed998747 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 @@ -110,14 +110,23 @@ describe('SetStatusModalWrapper', () => { }); describe('improvedEmojiPicker is true', () => { + const getEmojiPicker = () => wrapper.findComponent(EmojiPicker); + beforeEach(async () => { await initEmojiMock(); wrapper = createComponent({}, true); return initModal(); }); + it('renders emoji picker dropdown with custom positioning', () => { + expect(getEmojiPicker().props()).toMatchObject({ + right: false, + boundary: 'viewport', + }); + }); + it('sets emojiTag when clicking in emoji picker', async () => { - await wrapper.findComponent(EmojiPicker).vm.$emit('click', 'thumbsup'); + await getEmojiPicker().vm.$emit('click', 'thumbsup'); expect(wrapper.vm.emojiTag).toContain('data-name="thumbsup"'); }); diff --git a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js index 13887f28d22..d0792fa7b73 100644 --- a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js +++ b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js @@ -48,12 +48,16 @@ describe('UncollapsedReviewerList component', () => { }); it('renders re-request loading icon', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ loadingStates: { 1: 'loading' } }); expect(wrapper.find('[data-testid="re-request-button"]').props('loading')).toBe(true); }); it('renders re-request success icon', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ loadingStates: { 1: 'success' } }); expect(wrapper.find('[data-testid="re-request-success"]').exists()).toBe(true); @@ -98,6 +102,8 @@ describe('UncollapsedReviewerList component', () => { }); it('renders re-request loading icon', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ loadingStates: { 2: 'loading' } }); expect(wrapper.findAll('[data-testid="re-request-button"]').length).toBe(2); @@ -107,6 +113,8 @@ describe('UncollapsedReviewerList component', () => { }); it('renders re-request success icon', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ loadingStates: { 2: 'success' } }); expect(wrapper.findAll('[data-testid="re-request-button"]').length).toBe(1); diff --git a/spec/frontend/sidebar/participants_spec.js b/spec/frontend/sidebar/participants_spec.js index 1210f7c9531..94cdbe7f2ef 100644 --- a/spec/frontend/sidebar/participants_spec.js +++ b/spec/frontend/sidebar/participants_spec.js @@ -85,6 +85,8 @@ describe('Participants', () => { numberOfLessParticipants, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isShowingMoreParticipants: false, }); @@ -101,6 +103,8 @@ describe('Participants', () => { numberOfLessParticipants: 2, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isShowingMoreParticipants: true, }); @@ -129,6 +133,8 @@ describe('Participants', () => { numberOfLessParticipants: 2, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isShowingMoreParticipants: false, }); @@ -145,6 +151,8 @@ describe('Participants', () => { numberOfLessParticipants: 2, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isShowingMoreParticipants: true, }); 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 40bc6fe6aa5..c193bb08543 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 @@ -90,6 +90,8 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = /> <!----> + + <!----> </div> </div> </div> diff --git a/spec/frontend/snippets/components/snippet_blob_view_spec.js b/spec/frontend/snippets/components/snippet_blob_view_spec.js index b92c1907980..172089f9ee6 100644 --- a/spec/frontend/snippets/components/snippet_blob_view_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_view_spec.js @@ -157,6 +157,8 @@ describe('Blob Embeddable', () => { }); // mimic apollo's update + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ blobContent: wrapper.vm.onContentUpdate(apolloData), }); diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js index 2d5e0cfd615..daa9d6345b0 100644 --- a/spec/frontend/snippets/components/snippet_header_spec.js +++ b/spec/frontend/snippets/components/snippet_header_spec.js @@ -242,6 +242,8 @@ describe('Snippet header component', () => { // TODO: we should avoid `wrapper.setData` since they // are component internals. Let's use the apollo mock helpers // in a follow-up. + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ canCreateSnippet: true }); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/static_site_editor/components/edit_area_spec.js b/spec/frontend/static_site_editor/components/edit_area_spec.js index 1d6245e9dbb..a833fd9ff9e 100644 --- a/spec/frontend/static_site_editor/components/edit_area_spec.js +++ b/spec/frontend/static_site_editor/components/edit_area_spec.js @@ -132,6 +132,8 @@ describe('~/static_site_editor/components/edit_area.vue', () => { describe('when the mode changes', () => { const setInitialMode = (mode) => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ editorMode: mode }); }; @@ -207,6 +209,8 @@ describe('~/static_site_editor/components/edit_area.vue', () => { }); it('syncs matter changes to content in markdown mode', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ editorMode: EDITOR_TYPES.markdown }); const newSettings = { title: 'test' }; diff --git a/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/add_image_modal_spec.js b/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/add_image_modal_spec.js index 86ae016987d..c8c9f45618d 100644 --- a/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/add_image_modal_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/add_image_modal_spec.js @@ -48,6 +48,8 @@ describe('Add Image Modal', () => { const file = { name: 'some_file.png' }; wrapper.vm.$refs.uploadImageTab = { validateFile: jest.fn() }; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ file, description, tabIndex: IMAGE_TABS.UPLOAD_TAB }); findModal().vm.$emit('ok', { preventDefault }); @@ -60,6 +62,8 @@ describe('Add Image Modal', () => { it('emits an addImage event when a valid URL is specified', () => { const preventDefault = jest.fn(); const mockImage = { imageUrl: '/some/valid/url.png', description: 'some description' }; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ ...mockImage, tabIndex: IMAGE_TABS.URL_TAB }); findModal().vm.$emit('ok', { preventDefault }); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js index 6a2b89a8dcf..ddc96ed6832 100644 --- a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js @@ -13,7 +13,7 @@ const normalParagraphNode = buildMockParagraphNode( 'This is just normal paragraph. It has multiple sentences.', ); const identifierParagraphNode = buildMockParagraphNode( - `[another-identifier]: https://example.com "This example has a title" [identifier]: http://example1.com [this link]: http://example2.com`, + `[another-identifier]: https://example.com "This example has a title" [identifier]: http://example1.com [this link]: http://example.org`, ); describe('rich_content_editor/renderers_render_identifier_paragraph', () => { diff --git a/spec/frontend/terraform/components/states_table_actions_spec.js b/spec/frontend/terraform/components/states_table_actions_spec.js index 9d28e8ce294..fbe55306f37 100644 --- a/spec/frontend/terraform/components/states_table_actions_spec.js +++ b/spec/frontend/terraform/components/states_table_actions_spec.js @@ -293,6 +293,8 @@ describe('StatesTableActions', () => { describe('when state name is present', () => { beforeEach(async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ removeConfirmText: defaultProps.state.name }); findRemoveModal().vm.$emit('ok'); diff --git a/spec/frontend/tracking/tracking_initialization_spec.js b/spec/frontend/tracking/tracking_initialization_spec.js index 2b70aacc4cb..f1628ad9793 100644 --- a/spec/frontend/tracking/tracking_initialization_spec.js +++ b/spec/frontend/tracking/tracking_initialization_spec.js @@ -81,7 +81,8 @@ describe('Tracking', () => { it('should activate features based on what has been enabled', () => { initDefaultTrackers(); expect(snowplowSpy).toHaveBeenCalledWith('enableActivityTracking', 30, 30); - expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [standardContext]); + expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', 'GitLab', [standardContext]); + expect(snowplowSpy).toHaveBeenCalledWith('setDocumentTitle', 'GitLab'); expect(snowplowSpy).not.toHaveBeenCalledWith('enableFormTracking'); expect(snowplowSpy).not.toHaveBeenCalledWith('enableLinkClickTracking'); @@ -130,7 +131,7 @@ describe('Tracking', () => { it('includes those contexts alongside the standard context', () => { initDefaultTrackers(); - expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [ + expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', 'GitLab', [ standardContext, ...experimentContexts, ]); diff --git a/spec/frontend/version_check_image_spec.js b/spec/frontend/version_check_image_spec.js deleted file mode 100644 index 13bd104a91c..00000000000 --- a/spec/frontend/version_check_image_spec.js +++ /dev/null @@ -1,42 +0,0 @@ -import $ from 'jquery'; -import ClassSpecHelper from 'helpers/class_spec_helper'; -import VersionCheckImage from '~/version_check_image'; - -describe('VersionCheckImage', () => { - let testContext; - - beforeEach(() => { - testContext = {}; - }); - - describe('bindErrorEvent', () => { - ClassSpecHelper.itShouldBeAStaticMethod(VersionCheckImage, 'bindErrorEvent'); - - beforeEach(() => { - testContext.imageElement = $('<div></div>'); - }); - - it('registers an error event', () => { - jest.spyOn($.prototype, 'on').mockImplementation(() => {}); - // eslint-disable-next-line func-names - jest.spyOn($.prototype, 'off').mockImplementation(function () { - return this; - }); - - VersionCheckImage.bindErrorEvent(testContext.imageElement); - - expect($.prototype.off).toHaveBeenCalledWith('error'); - expect($.prototype.on).toHaveBeenCalledWith('error', expect.any(Function)); - }); - - it('hides the imageElement on error', () => { - jest.spyOn($.prototype, 'hide').mockImplementation(() => {}); - - VersionCheckImage.bindErrorEvent(testContext.imageElement); - - testContext.imageElement.trigger('error'); - - expect($.prototype.hide).toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js index af6624a6c43..36850e623c7 100644 --- a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js +++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js @@ -101,6 +101,8 @@ describe('MRWidget approvals', () => { }); it('shows loading message', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ fetchingApprovals: true }); return tick().then(() => { 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 a09269e869c..5a1f17573d4 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 @@ -153,6 +153,8 @@ describe('MRWidgetHeader', () => { gitpodEnabled: true, showGitpodButton: true, gitpodUrl: 'http://gitpod.localhost', + userPreferencesGitpodPath: '/-/profile/preferences#user_gitpod_enabled', + userProfileEnableGitpodPath: '/-/profile?user%5Bgitpod_enabled%5D=true', }; it('renders checkout branch button with modal trigger', () => { @@ -208,6 +210,8 @@ describe('MRWidgetHeader', () => { gitpodEnabled: true, showGitpodButton: true, gitpodUrl: 'http://gitpod.localhost', + userPreferencesGitpodPath: mrDefaultOptions.userPreferencesGitpodPath, + userProfileEnableGitpodPath: mrDefaultOptions.userProfileEnableGitpodPath, webIdeUrl, }); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js index d3221cc2fc7..27604868b3e 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js @@ -2,10 +2,15 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import WidgetRebase from '~/vue_merge_request_widget/components/states/mr_widget_rebase.vue'; import eventHub from '~/vue_merge_request_widget/event_hub'; +import ActionsButton from '~/vue_shared/components/actions_button.vue'; +import { + REBASE_BUTTON_KEY, + REBASE_WITHOUT_CI_BUTTON_KEY, +} from '~/vue_merge_request_widget/constants'; let wrapper; -function factory(propsData, mergeRequestWidgetGraphql) { +function createWrapper(propsData, mergeRequestWidgetGraphql, rebaseWithoutCiUi) { wrapper = shallowMount(WidgetRebase, { propsData, data() { @@ -19,7 +24,7 @@ function factory(propsData, mergeRequestWidgetGraphql) { }, }; }, - provide: { glFeatures: { mergeRequestWidgetGraphql } }, + provide: { glFeatures: { mergeRequestWidgetGraphql, rebaseWithoutCiUi } }, mocks: { $apollo: { queries: { @@ -31,8 +36,10 @@ function factory(propsData, mergeRequestWidgetGraphql) { } describe('Merge request widget rebase component', () => { - const findRebaseMessageEl = () => wrapper.find('[data-testid="rebase-message"]'); - const findRebaseMessageElText = () => findRebaseMessageEl().text(); + const findRebaseMessage = () => wrapper.find('[data-testid="rebase-message"]'); + const findRebaseMessageText = () => findRebaseMessage().text(); + const findRebaseButtonActions = () => wrapper.find(ActionsButton); + const findStandardRebaseButton = () => wrapper.find('[data-testid="standard-rebase-button"]'); afterEach(() => { wrapper.destroy(); @@ -40,10 +47,10 @@ describe('Merge request widget rebase component', () => { }); [true, false].forEach((mergeRequestWidgetGraphql) => { - describe(`widget graphql is ${mergeRequestWidgetGraphql ? 'enabled' : 'dislabed'}`, () => { - describe('While rebasing', () => { + describe(`widget graphql is ${mergeRequestWidgetGraphql ? 'enabled' : 'disabled'}`, () => { + describe('while rebasing', () => { it('should show progress message', () => { - factory( + createWrapper( { mr: { rebaseInProgress: true }, service: {}, @@ -51,24 +58,30 @@ describe('Merge request widget rebase component', () => { mergeRequestWidgetGraphql, ); - expect(findRebaseMessageElText()).toContain('Rebase in progress'); + expect(findRebaseMessageText()).toContain('Rebase in progress'); }); }); - describe('With permissions', () => { - it('it should render rebase button and warning message', () => { - factory( + describe('with permissions', () => { + const rebaseMock = jest.fn().mockResolvedValue(); + const pollMock = jest.fn().mockResolvedValue({}); + + it('renders the warning message', () => { + createWrapper( { mr: { rebaseInProgress: false, canPushToSourceBranch: true, }, - service: {}, + service: { + rebase: rebaseMock, + poll: pollMock, + }, }, mergeRequestWidgetGraphql, ); - const text = findRebaseMessageElText(); + const text = findRebaseMessageText(); expect(text).toContain('Merge blocked'); expect(text.replace(/\s\s+/g, ' ')).toContain( @@ -76,73 +89,195 @@ describe('Merge request widget rebase component', () => { ); }); - it('it should render error message when it fails', async () => { - factory( + it('renders an error message when rebasing has failed', async () => { + createWrapper( { mr: { rebaseInProgress: false, canPushToSourceBranch: true, }, - service: {}, + service: { + rebase: rebaseMock, + poll: pollMock, + }, }, mergeRequestWidgetGraphql, ); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ rebasingError: 'Something went wrong!' }); await nextTick(); - expect(findRebaseMessageElText()).toContain('Something went wrong!'); + expect(findRebaseMessageText()).toContain('Something went wrong!'); + }); + + describe('Rebase button with flag rebaseWithoutCiUi', () => { + beforeEach(() => { + createWrapper( + { + mr: { + rebaseInProgress: false, + canPushToSourceBranch: true, + }, + service: { + rebase: rebaseMock, + poll: pollMock, + }, + }, + mergeRequestWidgetGraphql, + { rebaseWithoutCiUi: true }, + ); + }); + + it('rebase button with actions is rendered', () => { + expect(findRebaseButtonActions().exists()).toBe(true); + expect(findStandardRebaseButton().exists()).toBe(false); + }); + + it('has rebase and rebase without CI actions', () => { + const actionNames = findRebaseButtonActions() + .props('actions') + .map((action) => action.key); + + expect(actionNames).toStrictEqual([REBASE_BUTTON_KEY, REBASE_WITHOUT_CI_BUTTON_KEY]); + }); + + it('defaults to rebase action', () => { + expect(findRebaseButtonActions().props('selectedKey')).toStrictEqual(REBASE_BUTTON_KEY); + }); + + it('starts the rebase when clicking', async () => { + // ActionButtons use the actions props instead of emitting + // a click event, therefore simulating the behavior here: + findRebaseButtonActions() + .props('actions') + .find((x) => x.key === REBASE_BUTTON_KEY) + .handle(); + + await nextTick(); + + expect(rebaseMock).toHaveBeenCalledWith({ skipCi: false }); + }); + + it('starts the CI-skipping rebase when clicking on "Rebase without CI"', async () => { + // ActionButtons use the actions props instead of emitting + // a click event, therefore simulating the behavior here: + findRebaseButtonActions() + .props('actions') + .find((x) => x.key === REBASE_WITHOUT_CI_BUTTON_KEY) + .handle(); + + await nextTick(); + + expect(rebaseMock).toHaveBeenCalledWith({ skipCi: true }); + }); + }); + + describe('Rebase button with rebaseWithoutCiUI flag disabled', () => { + beforeEach(() => { + createWrapper( + { + mr: { + rebaseInProgress: false, + canPushToSourceBranch: true, + }, + service: { + rebase: rebaseMock, + poll: pollMock, + }, + }, + mergeRequestWidgetGraphql, + ); + }); + + it('standard rebase button is rendered', () => { + expect(findStandardRebaseButton().exists()).toBe(true); + expect(findRebaseButtonActions().exists()).toBe(false); + }); + + it('calls rebase method with skip_ci false', () => { + findStandardRebaseButton().vm.$emit('click'); + + expect(rebaseMock).toHaveBeenCalledWith({ skipCi: false }); + }); }); }); - describe('Without permissions', () => { - it('should render a message explaining user does not have permissions', () => { - factory( + describe('without permissions', () => { + const exampleTargetBranch = 'fake-branch-to-test-with'; + + describe('UI text', () => { + beforeEach(() => { + createWrapper( + { + mr: { + rebaseInProgress: false, + canPushToSourceBranch: false, + targetBranch: exampleTargetBranch, + }, + service: {}, + }, + mergeRequestWidgetGraphql, + ); + }); + + it('renders a message explaining user does not have permissions', () => { + const text = findRebaseMessageText(); + + expect(text).toContain( + 'Merge blocked: the source branch must be rebased onto the target branch.', + ); + expect(text).toContain('the source branch must be rebased'); + }); + + it('renders the correct target branch name', () => { + const elem = findRebaseMessage(); + + expect(elem.text()).toContain( + 'Merge blocked: the source branch must be rebased onto the target branch.', + ); + }); + }); + + it('does not render the rebase actions button with rebaseWithoutCiUI flag enabled', () => { + createWrapper( { mr: { rebaseInProgress: false, canPushToSourceBranch: false, - targetBranch: 'foo', + targetBranch: exampleTargetBranch, }, service: {}, }, mergeRequestWidgetGraphql, + { rebaseWithoutCiUi: true }, ); - const text = findRebaseMessageElText(); - - expect(text).toContain( - 'Merge blocked: the source branch must be rebased onto the target branch.', - ); - expect(text).toContain('the source branch must be rebased'); + expect(findRebaseButtonActions().exists()).toBe(false); }); - it('should render the correct target branch name', () => { - const targetBranch = 'fake-branch-to-test-with'; - factory( + it('does not render the standard rebase button with rebaseWithoutCiUI flag disabled', () => { + createWrapper( { mr: { rebaseInProgress: false, canPushToSourceBranch: false, - targetBranch, + targetBranch: exampleTargetBranch, }, service: {}, }, mergeRequestWidgetGraphql, ); - const elem = findRebaseMessageEl(); - - expect(elem.text()).toContain( - `Merge blocked: the source branch must be rebased onto the target branch.`, - ); + expect(findStandardRebaseButton().exists()).toBe(false); }); }); describe('methods', () => { it('checkRebaseStatus', async () => { jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - factory( + createWrapper( { mr: {}, service: { diff --git a/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js b/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js index bdad0bada5f..1900b53ac11 100644 --- a/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js @@ -15,35 +15,12 @@ describe('Merge request widget merge checks failed state component', () => { }); it.each` - mrState | displayText - ${{ isPipelineFailed: true }} | ${'pipelineFailed'} - ${{ approvals: true, isApproved: false }} | ${'approvalNeeded'} - ${{ hasMergeableDiscussionsState: true }} | ${'unresolvedDiscussions'} + mrState | displayText + ${{ approvals: true, isApproved: false }} | ${'approvalNeeded'} + ${{ blockingMergeRequests: { total_count: 1 } }} | ${'blockingMergeRequests'} `('display $displayText text for $mrState', ({ mrState, displayText }) => { factory({ mr: mrState }); expect(wrapper.text()).toContain(MergeChecksFailed.i18n[displayText]); }); - - describe('unresolved discussions', () => { - it('renders jump to button', () => { - factory({ mr: { hasMergeableDiscussionsState: true } }); - - expect(wrapper.find('[data-testid="jumpToUnresolved"]').exists()).toBe(true); - }); - - it('renders resolve thread button', () => { - factory({ - mr: { - hasMergeableDiscussionsState: true, - createIssueToResolveDiscussionsPath: 'https://gitlab.com', - }, - }); - - expect(wrapper.find('[data-testid="resolveIssue"]').exists()).toBe(true); - expect(wrapper.find('[data-testid="resolveIssue"]').attributes('href')).toBe( - 'https://gitlab.com', - ); - }); - }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js index d0a6af9970e..52a56af454f 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 @@ -253,6 +253,8 @@ describe('MRWidgetAutoMergeEnabled', () => { factory({ ...defaultMrProps(), }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isCancellingAutoMerge: true, }); @@ -287,6 +289,8 @@ describe('MRWidgetAutoMergeEnabled', () => { factory({ ...defaultMrProps(), }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isRemovingSourceBranch: true, }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js index 5858654e518..4d05e732f48 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js @@ -60,6 +60,8 @@ describe('Commits header component', () => { it('has a chevron-right icon', () => { createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ expanded: false }); return wrapper.vm.$nextTick().then(() => { @@ -111,6 +113,8 @@ describe('Commits header component', () => { describe('when expanded', () => { beforeEach(() => { createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ expanded: true }); }); 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 89de160b02f..ec222e66a97 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 @@ -41,6 +41,8 @@ describe('MRWidgetConflicts', () => { ); if (mergeRequestWidgetGraphql) { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ userPermissions: { canMerge: propsData.mr.canMerge, diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js index 848677bf4d2..936d673768c 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js @@ -14,6 +14,8 @@ function factory(sourceBranchRemoved, mergeRequestWidgetGraphql) { }); if (mergeRequestWidgetGraphql) { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ state: { sourceBranchExists: !sourceBranchRemoved } }); } diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js index 7082a19a8e7..f4ecebbb40c 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -192,6 +192,8 @@ describe('ReadyToMerge', () => { it('should return "Merge in progress"', async () => { createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isMergingImmediately: true }); await Vue.nextTick(); @@ -260,6 +262,8 @@ describe('ReadyToMerge', () => { it('should return true when the vm instance is making request', async () => { createComponent({ mr: { isMergeAllowed: true } }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isMakingRequest: true }); await Vue.nextTick(); @@ -287,6 +291,8 @@ describe('ReadyToMerge', () => { jest .spyOn(wrapper.vm.service, 'merge') .mockReturnValue(returnPromise('merge_when_pipeline_succeeds')); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ removeSourceBranch: false }); wrapper.vm.handleMergeButtonClick(true); @@ -691,6 +697,8 @@ describe('ReadyToMerge', () => { true, ); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ loading: false, state: { diff --git a/spec/frontend/vue_mr_widget/components/terraform/mock_data.js b/spec/frontend/vue_mr_widget/components/terraform/mock_data.js index ae280146c22..8e46af5dfd6 100644 --- a/spec/frontend/vue_mr_widget/components/terraform/mock_data.js +++ b/spec/frontend/vue_mr_widget/components/terraform/mock_data.js @@ -1,6 +1,6 @@ export const invalidPlanWithName = { job_name: 'Invalid Plan', - job_path: '/path/to/ci/logs/1', + job_path: '/path/to/ci/logs/3', tf_report_error: 'api_error', }; @@ -20,12 +20,12 @@ export const validPlanWithoutName = { create: 10, update: 20, delete: 30, - job_path: '/path/to/ci/logs/1', + job_path: '/path/to/ci/logs/2', }; export const plans = { invalid_plan_one: invalidPlanWithName, - invalid_plan_two: invalidPlanWithName, + invalid_plan_two: invalidPlanWithoutName, valid_plan_one: validPlanWithName, valid_plan_two: validPlanWithoutName, }; diff --git a/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js b/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js index 364f849eb4f..9048975875a 100644 --- a/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js +++ b/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js @@ -43,6 +43,8 @@ describe('MrWidgetTerraformConainer', () => { mockPollingApi(200, plans, {}); return mountWrapper().then(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ loading: true }); return wrapper.vm.$nextTick(); }); diff --git a/spec/frontend/vue_mr_widget/extentions/terraform/index_spec.js b/spec/frontend/vue_mr_widget/extentions/terraform/index_spec.js new file mode 100644 index 00000000000..f8ea6fc23a2 --- /dev/null +++ b/spec/frontend/vue_mr_widget/extentions/terraform/index_spec.js @@ -0,0 +1,178 @@ +import MockAdapter from 'axios-mock-adapter'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import Poll from '~/lib/utils/poll'; +import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container'; +import { registerExtension } from '~/vue_merge_request_widget/components/extensions'; +import terraformExtension from '~/vue_merge_request_widget/extensions/terraform'; +import { + plans, + validPlanWithName, + validPlanWithoutName, + invalidPlanWithName, + invalidPlanWithoutName, +} from '../../components/terraform/mock_data'; + +describe('Terraform extension', () => { + let wrapper; + let mock; + + const endpoint = '/path/to/terraform/report.json'; + const successStatusCode = 200; + const errorStatusCode = 500; + + const findListItem = (at) => wrapper.findAllByTestId('extension-list-item').at(at); + + registerExtension(terraformExtension); + + const mockPollingApi = (response, body, header) => { + mock.onGet(endpoint).reply(response, body, header); + }; + + const createComponent = () => { + wrapper = mountExtended(extensionsContainer, { + propsData: { + mr: { + terraformReportsPath: endpoint, + }, + }, + }); + return axios.waitForAll(); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + describe('summary', () => { + describe('while loading', () => { + const loadingText = 'Loading Terraform reports...'; + it('should render loading text', async () => { + mockPollingApi(successStatusCode, plans, {}); + createComponent(); + + expect(wrapper.text()).toContain(loadingText); + await waitForPromises(); + expect(wrapper.text()).not.toContain(loadingText); + }); + }); + + describe('when the fetching fails', () => { + beforeEach(() => { + mockPollingApi(errorStatusCode, null, {}); + return createComponent(); + }); + + it('should generate one invalid plan and render correct summary text', () => { + expect(wrapper.text()).toContain('1 Terraform report failed to generate'); + }); + }); + + describe('when the fetching succeeds', () => { + describe.each` + responseType | response | summaryTitle | summarySubtitle + ${'1 invalid report'} | ${{ 0: invalidPlanWithName }} | ${'1 Terraform report failed to generate'} | ${''} + ${'2 valid reports'} | ${{ 0: validPlanWithName, 1: validPlanWithName }} | ${'2 Terraform reports were generated in your pipelines'} | ${''} + ${'1 valid and 2 invalid reports'} | ${{ 0: validPlanWithName, 1: invalidPlanWithName, 2: invalidPlanWithName }} | ${'Terraform report was generated in your pipelines'} | ${'2 Terraform reports failed to generate'} + `('and received $responseType', ({ response, summaryTitle, summarySubtitle }) => { + beforeEach(async () => { + mockPollingApi(successStatusCode, response, {}); + return createComponent(); + }); + + it(`should render correct summary text`, () => { + expect(wrapper.text()).toContain(summaryTitle); + + if (summarySubtitle) { + expect(wrapper.text()).toContain(summarySubtitle); + } + }); + }); + }); + }); + + describe('expanded data', () => { + beforeEach(async () => { + mockPollingApi(successStatusCode, plans, {}); + await createComponent(); + + wrapper.findByTestId('toggle-button').trigger('click'); + }); + + describe.each` + reportType | title | subtitle | logLink | lineNumber + ${'a valid report with name'} | ${`The job ${validPlanWithName.job_name} generated a report.`} | ${`Reported Resource Changes: ${validPlanWithName.create} to add, ${validPlanWithName.update} to change, ${validPlanWithName.delete} to delete`} | ${validPlanWithName.job_path} | ${0} + ${'a valid report without name'} | ${'A Terraform report was generated in your pipelines.'} | ${`Reported Resource Changes: ${validPlanWithoutName.create} to add, ${validPlanWithoutName.update} to change, ${validPlanWithoutName.delete} to delete`} | ${validPlanWithoutName.job_path} | ${1} + ${'an invalid report with name'} | ${`The job ${invalidPlanWithName.job_name} failed to generate a report.`} | ${'Generating the report caused an error.'} | ${invalidPlanWithName.job_path} | ${2} + ${'an invalid report without name'} | ${'A Terraform report failed to generate.'} | ${'Generating the report caused an error.'} | ${invalidPlanWithoutName.job_path} | ${3} + `('renders correct text for $reportType', ({ title, subtitle, logLink, lineNumber }) => { + it('renders correct text', () => { + expect(findListItem(lineNumber).text()).toContain(title); + expect(findListItem(lineNumber).text()).toContain(subtitle); + }); + + it(`${logLink ? 'renders' : "doesn't render"} the log link`, () => { + const logText = 'Full log'; + if (logLink) { + expect( + findListItem(lineNumber) + .find('[data-testid="extension-actions-button"]') + .attributes('href'), + ).toBe(logLink); + } else { + expect(findListItem(lineNumber).text()).not.toContain(logText); + } + }); + }); + }); + + describe('polling', () => { + let pollRequest; + let pollStop; + + beforeEach(() => { + pollRequest = jest.spyOn(Poll.prototype, 'makeRequest'); + pollStop = jest.spyOn(Poll.prototype, 'stop'); + }); + + afterEach(() => { + pollRequest.mockRestore(); + pollStop.mockRestore(); + }); + + describe('successful poll', () => { + beforeEach(() => { + mockPollingApi(successStatusCode, plans, {}); + + return createComponent(); + }); + + it('does not make additional requests after poll is successful', () => { + expect(pollRequest).toHaveBeenCalledTimes(1); + expect(pollStop).toHaveBeenCalledTimes(1); + }); + }); + + describe('polling fails', () => { + beforeEach(() => { + mockPollingApi(errorStatusCode, null, {}); + return createComponent(); + }); + + it('generates one broken plan', () => { + expect(wrapper.text()).toContain('1 Terraform report failed to generate'); + }); + + it('does not make additional requests after poll is unsuccessful', () => { + expect(pollRequest).toHaveBeenCalledTimes(1); + expect(pollStop).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/mock_data.js b/spec/frontend/vue_mr_widget/mock_data.js index 4538c1320d0..20d00a116bb 100644 --- a/spec/frontend/vue_mr_widget/mock_data.js +++ b/spec/frontend/vue_mr_widget/mock_data.js @@ -282,6 +282,8 @@ export default { gitpod_enabled: true, show_gitpod_button: true, gitpod_url: 'http://gitpod.localhost', + user_preferences_gitpod_path: '/-/profile/preferences#user_gitpod_enabled', + user_profile_enable_gitpod_path: '/-/profile?user%5Bgitpod_enabled%5D=true', }; export const mockStore = { diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js index 8d41f6620ff..56c9bae0b76 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -9,6 +9,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { securityReportMergeRequestDownloadPathsQueryResponse } from 'jest/vue_shared/security_reports/mock_data'; import api from '~/api'; import axios from '~/lib/utils/axios_utils'; +import Poll from '~/lib/utils/poll'; import { setFaviconOverlay } from '~/lib/utils/favicon'; import notify from '~/lib/utils/notify'; import SmartInterval from '~/smart_interval'; @@ -28,6 +29,8 @@ import { workingExtension, collapsedDataErrorExtension, fullDataErrorExtension, + pollingExtension, + pollingErrorExtension, } from './test_extensions'; jest.mock('~/api.js'); @@ -897,13 +900,19 @@ describe('MrWidgetOptions', () => { }); describe('mock extension', () => { + let pollRequest; + beforeEach(() => { + pollRequest = jest.spyOn(Poll.prototype, 'makeRequest'); + registerExtension(workingExtension); createComponent(); }); afterEach(() => { + pollRequest.mockRestore(); + registeredExtensions.extensions = []; }); @@ -957,6 +966,66 @@ describe('MrWidgetOptions', () => { expect(collapsedSection.find(GlButton).exists()).toBe(true); expect(collapsedSection.find(GlButton).text()).toBe('Full report'); }); + + it('extension polling is not called if enablePolling flag is not passed', () => { + // called one time due to parent component polling (mount) + expect(pollRequest).toHaveBeenCalledTimes(1); + }); + }); + + describe('mock polling extension', () => { + let pollRequest; + let pollStop; + + beforeEach(() => { + pollRequest = jest.spyOn(Poll.prototype, 'makeRequest'); + pollStop = jest.spyOn(Poll.prototype, 'stop'); + }); + + afterEach(() => { + pollRequest.mockRestore(); + pollStop.mockRestore(); + + registeredExtensions.extensions = []; + }); + + describe('success', () => { + beforeEach(() => { + registerExtension(pollingExtension); + + createComponent(); + }); + + it('does not make additional requests after poll is successful', () => { + // called two times due to parent component polling (mount) and extension polling + expect(pollRequest).toHaveBeenCalledTimes(2); + expect(pollStop).toHaveBeenCalledTimes(1); + }); + }); + + describe('error', () => { + let captureException; + + beforeEach(() => { + captureException = jest.spyOn(Sentry, 'captureException'); + + registerExtension(pollingErrorExtension); + + createComponent(); + }); + + it('does not make additional requests after poll has failed', () => { + // called two times due to parent component polling (mount) and extension polling + expect(pollRequest).toHaveBeenCalledTimes(2); + expect(pollStop).toHaveBeenCalledTimes(1); + }); + + it('captures sentry error and displays error when poll has failed', () => { + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledWith(new Error('Fetch error')); + expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('error'); + }); + }); }); describe('mock extension errors', () => { diff --git a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js index 6eb68a1b00d..3cdb4265ef0 100644 --- a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js +++ b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js @@ -15,6 +15,8 @@ describe('MergeRequestStore', () => { gitpodEnabled: mockData.gitpod_enabled, showGitpodButton: mockData.show_gitpod_button, gitpodUrl: mockData.gitpod_url, + userPreferencesGitpodPath: mockData.user_preferences_gitpod_path, + userProfileEnableGitpodPath: mockData.user_profile_enable_gitpod_path, }); }); diff --git a/spec/frontend/vue_mr_widget/test_extensions.js b/spec/frontend/vue_mr_widget/test_extensions.js index c7ff02ab726..986c1d6545a 100644 --- a/spec/frontend/vue_mr_widget/test_extensions.js +++ b/spec/frontend/vue_mr_widget/test_extensions.js @@ -97,3 +97,13 @@ export const fullDataErrorExtension = { }, }, }; + +export const pollingExtension = { + ...workingExtension, + enablePolling: true, +}; + +export const pollingErrorExtension = { + ...collapsedDataErrorExtension, + enablePolling: true, +}; 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 1fc655f1ebc..221beed744b 100644 --- a/spec/frontend/vue_shared/alert_details/alert_details_spec.js +++ b/spec/frontend/vue_shared/alert_details/alert_details_spec.js @@ -349,6 +349,8 @@ describe('AlertDetails', () => { ${1} | ${'metrics'} ${2} | ${'activity'} `('will navigate to the correct tab via $tabId', ({ index, tabId }) => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentTabIndex: index }); expect($router.replace).toHaveBeenCalledWith({ name: 'tab', params: { tabId } }); }); diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js index 9ae45071f45..29e0eee2c9a 100644 --- a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js +++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js @@ -109,6 +109,8 @@ describe('Alert Details Sidebar Assignees', () => { }); it('renders a unassigned option', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isDropdownSearching: false }); await wrapper.vm.$nextTick(); expect(findDropdown().text()).toBe('Unassigned'); @@ -120,6 +122,8 @@ describe('Alert Details Sidebar Assignees', () => { it('calls `$apollo.mutate` with `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', async () => { jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isDropdownSearching: false }); await wrapper.vm.$nextTick(); @@ -136,6 +140,8 @@ describe('Alert Details Sidebar Assignees', () => { }); it('emits an error when request contains error messages', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isDropdownSearching: false }); const errorMutationResult = { data: { diff --git a/spec/frontend/vue_shared/components/chronic_duration_input_spec.js b/spec/frontend/vue_shared/components/chronic_duration_input_spec.js index 530d01402c6..083a5f60d1d 100644 --- a/spec/frontend/vue_shared/components/chronic_duration_input_spec.js +++ b/spec/frontend/vue_shared/components/chronic_duration_input_spec.js @@ -315,6 +315,8 @@ describe('vue_shared/components/chronic_duration_input', () => { }); it('passes updated prop via v-model', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ value: MOCK_VALUE }); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/vue_shared/components/clipboard_button_spec.js b/spec/frontend/vue_shared/components/clipboard_button_spec.js index 33445923a49..fca5e664a96 100644 --- a/spec/frontend/vue_shared/components/clipboard_button_spec.js +++ b/spec/frontend/vue_shared/components/clipboard_button_spec.js @@ -1,8 +1,16 @@ import { GlButton } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import initCopyToClipboard from '~/behaviors/copy_to_clipboard'; +import { nextTick } from 'vue'; + +import initCopyToClipboard, { + CLIPBOARD_SUCCESS_EVENT, + CLIPBOARD_ERROR_EVENT, + I18N_ERROR_MESSAGE, +} from '~/behaviors/copy_to_clipboard'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +jest.mock('lodash/uniqueId', () => (prefix) => (prefix ? `${prefix}1` : 1)); + describe('clipboard button', () => { let wrapper; @@ -15,6 +23,42 @@ describe('clipboard button', () => { const findButton = () => wrapper.find(GlButton); + const expectConfirmationTooltip = async ({ event, message }) => { + const title = 'Copy this value'; + + createWrapper({ + text: 'copy me', + title, + }); + + wrapper.vm.$root.$emit = jest.fn(); + + const button = findButton(); + + expect(button.attributes()).toMatchObject({ + title, + 'aria-label': title, + }); + + await button.trigger(event); + + expect(wrapper.vm.$root.$emit).toHaveBeenCalledWith('bv::show::tooltip', 'clipboard-button-1'); + + expect(button.attributes()).toMatchObject({ + title: message, + 'aria-label': message, + }); + + jest.runAllTimers(); + await nextTick(); + + expect(button.attributes()).toMatchObject({ + title, + 'aria-label': title, + }); + expect(wrapper.vm.$root.$emit).toHaveBeenCalledWith('bv::hide::tooltip', 'clipboard-button-1'); + }; + afterEach(() => { wrapper.destroy(); wrapper = null; @@ -99,6 +143,32 @@ describe('clipboard button', () => { expect(findButton().props('variant')).toBe(variant); }); + describe('confirmation tooltip', () => { + it('adds `id` and `data-clipboard-handle-tooltip` attributes to button', () => { + createWrapper({ + text: 'copy me', + title: 'Copy this value', + }); + + expect(findButton().attributes()).toMatchObject({ + id: 'clipboard-button-1', + 'data-clipboard-handle-tooltip': 'false', + 'aria-live': 'polite', + }); + }); + + it('shows success tooltip after successful copy', () => { + expectConfirmationTooltip({ + event: CLIPBOARD_SUCCESS_EVENT, + message: ClipboardButton.i18n.copied, + }); + }); + + it('shows error tooltip after failed copy', () => { + expectConfirmationTooltip({ event: CLIPBOARD_ERROR_EVENT, message: I18N_ERROR_MESSAGE }); + }); + }); + describe('integration', () => { it('actually copies to clipboard', () => { initCopyToClipboard(); diff --git a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js index af7f85769aa..a179afccae0 100644 --- a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js +++ b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js @@ -10,6 +10,7 @@ describe('Confirm Danger Modal', () => { const phrase = 'En Taro Adun'; const buttonText = 'Click me!'; const buttonClass = 'gl-w-full'; + const buttonVariant = 'info'; const modalId = CONFIRM_DANGER_MODAL_ID; const findBtn = () => wrapper.findComponent(GlButton); @@ -21,6 +22,7 @@ describe('Confirm Danger Modal', () => { propsData: { buttonText, buttonClass, + buttonVariant, phrase, ...props, }, @@ -57,6 +59,10 @@ describe('Confirm Danger Modal', () => { expect(findBtn().classes()).toContain(buttonClass); }); + it('passes `buttonVariant` prop to button', () => { + expect(findBtn().attributes('variant')).toBe(buttonVariant); + }); + it('will emit `confirm` when the modal confirms', () => { expect(wrapper.emitted('confirm')).toBeUndefined(); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js index 64d15884333..4e9eac2dde2 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js @@ -122,6 +122,8 @@ describe('FilteredSearchBarRoot', () => { describe('sortDirectionIcon', () => { it('returns string "sort-lowest" when `selectedSortDirection` is "ascending"', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedSortDirection: SortDirection.ascending, }); @@ -130,6 +132,8 @@ describe('FilteredSearchBarRoot', () => { }); it('returns string "sort-highest" when `selectedSortDirection` is "descending"', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedSortDirection: SortDirection.descending, }); @@ -140,6 +144,8 @@ describe('FilteredSearchBarRoot', () => { describe('sortDirectionTooltip', () => { it('returns string "Sort direction: Ascending" when `selectedSortDirection` is "ascending"', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedSortDirection: SortDirection.ascending, }); @@ -148,6 +154,8 @@ describe('FilteredSearchBarRoot', () => { }); it('returns string "Sort direction: Descending" when `selectedSortDirection` is "descending"', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedSortDirection: SortDirection.descending, }); @@ -158,6 +166,8 @@ describe('FilteredSearchBarRoot', () => { describe('filteredRecentSearches', () => { it('returns array of recent searches filtering out any string type (unsupported) items', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ recentSearches: [{ foo: 'bar' }, 'foo'], }); @@ -169,6 +179,8 @@ describe('FilteredSearchBarRoot', () => { }); it('returns array of recent searches sanitizing any duplicate token values', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ recentSearches: [ [tokenValueAuthor, tokenValueLabel, tokenValueMilestone, tokenValueLabel], @@ -198,6 +210,8 @@ describe('FilteredSearchBarRoot', () => { describe('filterValue', () => { it('emits component event `onFilter` with empty array and false when filter was never selected', () => { wrapper = createComponent({ initialFilterValue: [tokenValueEmpty] }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ initialRender: false, filterValue: [tokenValueEmpty], @@ -210,6 +224,8 @@ describe('FilteredSearchBarRoot', () => { it('emits component event `onFilter` with empty array and true when initially selected filter value was cleared', () => { wrapper = createComponent({ initialFilterValue: [tokenValueLabel] }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ initialRender: false, filterValue: [tokenValueEmpty], @@ -264,6 +280,8 @@ describe('FilteredSearchBarRoot', () => { describe('handleSortDirectionClick', () => { beforeEach(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedSortOption: mockSortOptions[0], }); @@ -312,6 +330,8 @@ describe('FilteredSearchBarRoot', () => { const mockFilters = [tokenValueAuthor, 'foo']; beforeEach(async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ filterValue: mockFilters, }); @@ -376,6 +396,8 @@ describe('FilteredSearchBarRoot', () => { describe('template', () => { beforeEach(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedSortOption: mockSortOptions[0], selectedSortDirection: SortDirection.descending, diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js index b29c394e7ae..5865c6a41b8 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js @@ -10,10 +10,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import { - DEFAULT_LABEL_ANY, - DEFAULT_NONE_ANY, -} from '~/vue_shared/components/filtered_search_bar/constants'; +import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -227,6 +224,8 @@ describe('AuthorToken', () => { expect(getAvatarEl().props('src')).toBe(mockAuthors[0].avatar_url); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ authors: [ { @@ -274,7 +273,7 @@ describe('AuthorToken', () => { expect(wrapper.find(GlDropdownDivider).exists()).toBe(false); }); - it('renders `DEFAULT_LABEL_ANY` as default suggestions', async () => { + it('renders `DEFAULT_NONE_ANY` as default suggestions', async () => { wrapper = createComponent({ active: true, config: { ...mockAuthorToken, preloadedAuthors: mockPreloadedAuthors }, @@ -285,8 +284,9 @@ describe('AuthorToken', () => { const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); - expect(suggestions).toHaveLength(1 + currentUserLength); - expect(suggestions.at(0).text()).toBe(DEFAULT_LABEL_ANY.text); + expect(suggestions).toHaveLength(2 + currentUserLength); + expect(suggestions.at(0).text()).toBe(DEFAULT_NONE_ANY[0].text); + expect(suggestions.at(1).text()).toBe(DEFAULT_NONE_ANY[1].text); }); it('emits listeners in the base-token', () => { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js index f3e8b2d0c1b..cd8be765fb5 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js @@ -121,6 +121,8 @@ describe('BranchToken', () => { beforeEach(async () => { wrapper = createComponent({ value: { data: mockBranches[0].name } }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ branches: mockBranches, }); 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 index 36071c900df..ed9ac7c271e 100644 --- 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 @@ -123,6 +123,8 @@ describe('EmojiToken', () => { value: { data: `"${mockEmojis[0].name}"` }, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ emojis: mockEmojis, }); 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 f55fb2836e3..b9af71ad8a7 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 @@ -144,6 +144,8 @@ describe('LabelToken', () => { beforeEach(async () => { wrapper = createComponent({ value: { data: `"${mockRegularLabel.title}"` } }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ labels: mockLabels, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js index 4a098db33c5..c0d8b5fd139 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js @@ -121,6 +121,8 @@ describe('MilestoneToken', () => { beforeEach(async () => { wrapper = createComponent({ value: { data: `"${mockRegularMilestone.title}"` } }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ milestones: mockMilestones, }); diff --git a/spec/frontend/vue_shared/components/gitlab_version_check_spec.js b/spec/frontend/vue_shared/components/gitlab_version_check_spec.js new file mode 100644 index 00000000000..b673e5407d4 --- /dev/null +++ b/spec/frontend/vue_shared/components/gitlab_version_check_spec.js @@ -0,0 +1,77 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import flushPromises from 'helpers/flush_promises'; +import axios from '~/lib/utils/axios_utils'; +import GitlabVersionCheck from '~/vue_shared/components/gitlab_version_check.vue'; + +describe('GitlabVersionCheck', () => { + let wrapper; + let mock; + + const defaultResponse = { + code: 200, + res: { severity: 'success' }, + }; + + const createComponent = (mockResponse) => { + const response = { + ...defaultResponse, + ...mockResponse, + }; + + mock = new MockAdapter(axios); + mock.onGet().replyOnce(response.code, response.res); + + wrapper = shallowMount(GitlabVersionCheck); + }; + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + const findGlBadge = () => wrapper.findComponent(GlBadge); + + describe('template', () => { + describe.each` + description | mockResponse | renders + ${'successful but null'} | ${{ code: 200, res: null }} | ${false} + ${'successful and valid'} | ${{ code: 200, res: { severity: 'success' } }} | ${true} + ${'an error'} | ${{ code: 500, res: null }} | ${false} + `('version_check.json response', ({ description, mockResponse, renders }) => { + describe(`is ${description}`, () => { + beforeEach(async () => { + createComponent(mockResponse); + await flushPromises(); // Ensure we wrap up the axios call + }); + + it(`does${renders ? '' : ' not'} render GlBadge`, () => { + expect(findGlBadge().exists()).toBe(renders); + }); + }); + }); + + describe.each` + mockResponse | expectedUI + ${{ code: 200, res: { severity: 'success' } }} | ${{ title: 'Up to date', variant: 'success' }} + ${{ code: 200, res: { severity: 'warning' } }} | ${{ title: 'Update available', variant: 'warning' }} + ${{ code: 200, res: { severity: 'danger' } }} | ${{ title: 'Update ASAP', variant: 'danger' }} + `('badge ui', ({ mockResponse, expectedUI }) => { + describe(`when response is ${mockResponse.res.severity}`, () => { + beforeEach(async () => { + createComponent(mockResponse); + await flushPromises(); // Ensure we wrap up the axios call + }); + + it(`title is ${expectedUI.title}`, () => { + expect(findGlBadge().text()).toBe(expectedUI.title); + }); + + it(`variant is ${expectedUI.variant}`, () => { + expect(findGlBadge().attributes('variant')).toBe(expectedUI.variant); + }); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/line_numbers_spec.js b/spec/frontend/vue_shared/components/line_numbers_spec.js index 5bedd0ccd02..38c26226863 100644 --- a/spec/frontend/vue_shared/components/line_numbers_spec.js +++ b/spec/frontend/vue_shared/components/line_numbers_spec.js @@ -13,7 +13,6 @@ describe('Line Numbers component', () => { const findGlIcon = () => wrapper.findComponent(GlIcon); const findLineNumbers = () => wrapper.findAllComponents(GlLink); const findFirstLineNumber = () => findLineNumbers().at(0); - const findSecondLineNumber = () => findLineNumbers().at(1); beforeEach(() => createComponent()); @@ -24,7 +23,7 @@ describe('Line Numbers component', () => { expect(findLineNumbers().length).toBe(lines); expect(findFirstLineNumber().attributes()).toMatchObject({ id: 'L1', - href: '#L1', + to: '#LC1', }); }); @@ -35,37 +34,4 @@ describe('Line Numbers component', () => { }); }); }); - - describe('clicking a line number', () => { - let firstLineNumber; - let firstLineNumberElement; - - beforeEach(() => { - firstLineNumber = findFirstLineNumber(); - firstLineNumberElement = firstLineNumber.element; - - jest.spyOn(firstLineNumberElement, 'scrollIntoView'); - jest.spyOn(firstLineNumberElement.classList, 'add'); - jest.spyOn(firstLineNumberElement.classList, 'remove'); - - firstLineNumber.vm.$emit('click'); - }); - - it('adds the highlight (hll) class', () => { - expect(firstLineNumberElement.classList.add).toHaveBeenCalledWith('hll'); - }); - - it('removes the highlight (hll) class from a previously highlighted line', () => { - findSecondLineNumber().vm.$emit('click'); - - expect(firstLineNumberElement.classList.remove).toHaveBeenCalledWith('hll'); - }); - - it('scrolls the line into view', () => { - expect(firstLineNumberElement.scrollIntoView).toHaveBeenCalledWith({ - behavior: 'smooth', - block: 'center', - }); - }); - }); }); diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js index 76e1a1162ad..0d90ca7f1f6 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import AxiosMockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import { TEST_HOST, FIXTURES_PATH } from 'spec/test_constants'; @@ -242,6 +243,41 @@ describe('Markdown field component', () => { expect(dropzoneSpy).toHaveBeenCalled(); }); + + describe('mentioning all users', () => { + const users = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((i) => `user_${i}`); + + it('shows warning on mention of all users', async () => { + axiosMock.onPost(markdownPreviewPath).reply(200, { references: { users } }); + + subject.setProps({ textareaValue: 'hello @all' }); + + await axios.waitFor(markdownPreviewPath).then(() => { + expect(subject.text()).toContain( + 'You are about to add 11 people to the discussion. They will all receive a notification.', + ); + }); + }); + + it('removes warning when all mention is removed', async () => { + axiosMock.onPost(markdownPreviewPath).reply(200, { references: { users } }); + + subject.setProps({ textareaValue: 'hello @all' }); + + await axios.waitFor(markdownPreviewPath); + + jest.spyOn(axios, 'post'); + + subject.setProps({ textareaValue: 'hello @allan' }); + + await nextTick(); + + expect(axios.post).not.toHaveBeenCalled(); + expect(subject.text()).not.toContain( + 'You are about to add 11 people to the discussion. They will all receive a notification.', + ); + }); + }); }); }); diff --git a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js index acf97713885..b330b4f5657 100644 --- a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js +++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js @@ -313,6 +313,8 @@ describe('AlertManagementEmptyState', () => { it('returns correctly applied filter search values', async () => { const searchTerm = 'foo'; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchTerm, }); @@ -330,6 +332,8 @@ describe('AlertManagementEmptyState', () => { }); it('updates props `searchTerm` and `authorUsername` with empty values when passed filters param is empty', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ authorUsername: 'foo', searchTerm: 'bar', diff --git a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap index 23cf6ef9785..e8d76991b90 100644 --- a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap +++ b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap @@ -3,7 +3,7 @@ exports[`Package code instruction multiline to match the snapshot 1`] = ` <div> <label - for="instruction-input_3" + for="instruction-input_1" > foo_label </label> @@ -23,7 +23,7 @@ multiline text exports[`Package code instruction single line to match the default snapshot 1`] = ` <div> <label - for="instruction-input_2" + for="instruction-input_1" > foo_label </label> @@ -37,7 +37,7 @@ exports[`Package code instruction single line to match the default snapshot 1`] <input class="form-control gl-font-monospace" data-testid="instruction-input" - id="instruction-input_2" + id="instruction-input_1" readonly="readonly" type="text" /> @@ -47,9 +47,12 @@ exports[`Package code instruction single line to match the default snapshot 1`] data-testid="instruction-button" > <button - aria-label="Copy this value" + aria-label="Copy npm install command" + aria-live="polite" class="btn input-group-text btn-default btn-md gl-button btn-default-secondary btn-icon" + data-clipboard-handle-tooltip="false" data-clipboard-text="npm i @my-package" + id="clipboard-button-1" title="Copy npm install command" type="button" > diff --git a/spec/frontend/vue_shared/components/registry/code_instruction_spec.js b/spec/frontend/vue_shared/components/registry/code_instruction_spec.js index 4ec608aaf07..3a2ea263a05 100644 --- a/spec/frontend/vue_shared/components/registry/code_instruction_spec.js +++ b/spec/frontend/vue_shared/components/registry/code_instruction_spec.js @@ -3,6 +3,8 @@ import Tracking from '~/tracking'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; +jest.mock('lodash/uniqueId', () => (prefix) => (prefix ? `${prefix}1` : 1)); + describe('Package code instruction', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js b/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js index a5a099d803a..5336ecc614c 100644 --- a/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js @@ -68,6 +68,8 @@ describe('IssuableMoveDropdown', () => { describe('searchKey', () => { it('calls `fetchProjects` with value of the prop', async () => { jest.spyOn(wrapper.vm, 'fetchProjects'); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchKey: 'foo', }); @@ -143,6 +145,8 @@ describe('IssuableMoveDropdown', () => { `( 'returns $returnValue when selectedProject and provided project param $title', async ({ project, selectedProject, returnValue }) => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedProject, }); @@ -154,6 +158,8 @@ describe('IssuableMoveDropdown', () => { ); it('returns false when selectedProject is null', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedProject: null, }); @@ -206,6 +212,8 @@ describe('IssuableMoveDropdown', () => { }); it('renders gl-loading-icon component when projectsListLoading prop is true', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ projectsListLoading: true, }); @@ -216,6 +224,8 @@ describe('IssuableMoveDropdown', () => { }); it('renders gl-dropdown-item components for available projects', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ projects: mockProjects, selectedProject: mockProjects[0], @@ -234,6 +244,8 @@ describe('IssuableMoveDropdown', () => { }); it('renders string "No matching results" when search does not yield any matches', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchKey: 'foo', }); @@ -241,6 +253,8 @@ describe('IssuableMoveDropdown', () => { // Wait for `searchKey` watcher to run. await wrapper.vm.$nextTick(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ projects: [], projectsListLoading: false, @@ -254,6 +268,8 @@ describe('IssuableMoveDropdown', () => { }); it('renders string "Failed to load projects" when loading projects list fails', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ projects: [], projectsListLoading: false, @@ -273,6 +289,8 @@ describe('IssuableMoveDropdown', () => { expect(moveButtonEl.text()).toBe('Move'); expect(moveButtonEl.attributes('disabled')).toBe('true'); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedProject: mockProjects[0], }); @@ -303,6 +321,8 @@ describe('IssuableMoveDropdown', () => { }); it('gl-dropdown component prevents dropdown body from closing on `hide` event when `projectItemClick` prop is true', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ projectItemClick: true, }); @@ -326,6 +346,8 @@ describe('IssuableMoveDropdown', () => { }); it('sets project for clicked gl-dropdown-item to selectedProject', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ projects: mockProjects, }); @@ -338,6 +360,8 @@ describe('IssuableMoveDropdown', () => { }); it('hides dropdown and emits `move-issuable` event when move button is clicked', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedProject: mockProjects[0], }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js index 1fe85637a62..0eff6a1dace 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js @@ -43,6 +43,8 @@ describe('DropdownContentsCreateView', () => { }); it('returns `true` when `labelCreateInProgress` is true', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ labelTitle: 'Foo', selectedColor: '#ff0000', @@ -55,6 +57,8 @@ describe('DropdownContentsCreateView', () => { }); it('returns `false` when label title and color is defined and create request is not already in progress', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ labelTitle: 'Foo', selectedColor: '#ff0000', @@ -99,6 +103,8 @@ describe('DropdownContentsCreateView', () => { describe('handleCreateClick', () => { it('calls action `createLabel` with object containing `labelTitle` & `selectedColor`', () => { jest.spyOn(wrapper.vm, 'createLabel').mockImplementation(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ labelTitle: 'Foo', selectedColor: '#ff0000', @@ -164,6 +170,8 @@ describe('DropdownContentsCreateView', () => { }); it('renders color input element', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedColor: '#ff0000', }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js index 80b8edd28ba..93a0e2f75bb 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js @@ -63,6 +63,8 @@ describe('DropdownContentsLabelsView', () => { describe('computed', () => { describe('visibleLabels', () => { it('returns matching labels filtered with `searchKey`', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchKey: 'bug', }); @@ -72,6 +74,8 @@ describe('DropdownContentsLabelsView', () => { }); it('returns matching labels with fuzzy filtering', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchKey: 'bg', }); @@ -82,6 +86,8 @@ describe('DropdownContentsLabelsView', () => { }); it('returns all labels when `searchKey` is empty', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchKey: '', }); @@ -100,6 +106,8 @@ describe('DropdownContentsLabelsView', () => { `( 'returns $returnValue when searchKey is "$searchKey" and visibleLabels is $labelsDescription', async ({ searchKey, labels, returnValue }) => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchKey, }); @@ -161,6 +169,8 @@ describe('DropdownContentsLabelsView', () => { describe('handleKeyDown', () => { it('decreases `currentHighlightItem` value by 1 when Up arrow key is pressed', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentHighlightItem: 1, }); @@ -173,6 +183,8 @@ describe('DropdownContentsLabelsView', () => { }); it('increases `currentHighlightItem` value by 1 when Down arrow key is pressed', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentHighlightItem: 1, }); @@ -185,6 +197,8 @@ describe('DropdownContentsLabelsView', () => { }); it('resets the search text when the Enter key is pressed', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentHighlightItem: 1, searchKey: 'bug', @@ -201,6 +215,8 @@ describe('DropdownContentsLabelsView', () => { it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => { jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentHighlightItem: 2, }); @@ -220,6 +236,8 @@ describe('DropdownContentsLabelsView', () => { it('calls action `toggleDropdownContents` when Esc key is pressed', () => { jest.spyOn(wrapper.vm, 'toggleDropdownContents').mockImplementation(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentHighlightItem: 1, }); @@ -233,6 +251,8 @@ describe('DropdownContentsLabelsView', () => { it('calls action `scrollIntoViewIfNeeded` in next tick when any key is pressed', () => { jest.spyOn(wrapper.vm, 'scrollIntoViewIfNeeded').mockImplementation(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentHighlightItem: 1, }); @@ -320,6 +340,8 @@ describe('DropdownContentsLabelsView', () => { }); it('renders label element with `highlight` set to true when value of `currentHighlightItem` is more than -1', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentHighlightItem: 0, }); @@ -332,6 +354,8 @@ describe('DropdownContentsLabelsView', () => { }); it('renders element containing "No matching results" when `searchKey` does not match with any label', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchKey: 'abc', }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js index d8491334b5d..3ceed670d77 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js @@ -1,4 +1,4 @@ -import { GlLoadingIcon, GlLink } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; @@ -9,6 +9,7 @@ import { workspaceLabelsQueries } from '~/sidebar/constants'; import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue'; import createLabelMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql'; import { + mockRegularLabel, mockSuggestedColors, createLabelSuccessfulResponse, workspaceLabelsQueryResponse, @@ -25,8 +26,18 @@ const userRecoverableError = { errors: ['Houston, we have a problem'], }; +const titleTakenError = { + data: { + labelCreate: { + label: mockRegularLabel, + errors: ['Title has already been taken'], + }, + }, +}; + const createLabelSuccessHandler = jest.fn().mockResolvedValue(createLabelSuccessfulResponse); const createLabelUserRecoverableErrorHandler = jest.fn().mockResolvedValue(userRecoverableError); +const createLabelDuplicateErrorHandler = jest.fn().mockResolvedValue(titleTakenError); const createLabelErrorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); describe('DropdownContentsCreateView', () => { @@ -208,4 +219,17 @@ describe('DropdownContentsCreateView', () => { expect(createFlash).toHaveBeenCalled(); }); + + it('displays error in alert if label title is already taken', async () => { + createComponent({ mutationHandler: createLabelDuplicateErrorHandler }); + fillLabelAttributes(); + await nextTick(); + + findCreateButton().vm.$emit('click'); + await waitForPromises(); + + expect(wrapper.findComponent(GlAlert).text()).toEqual( + titleTakenError.data.labelCreate.errors[0], + ); + }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js index 6f5a4b7e613..7f6770e0bea 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js @@ -110,6 +110,19 @@ describe('DropdownContentsLabelsView', () => { }); }); + it('first item is highlighted when search is not empty', async () => { + createComponent({ + queryHandler: jest.fn().mockResolvedValue(workspaceLabelsQueryResponse), + searchKey: 'Label', + }); + await makeObserverAppear(); + await waitForPromises(); + await nextTick(); + + expect(findLabelsList().exists()).toBe(true); + expect(findFirstLabel().attributes('active')).toBe('true'); + }); + it('when search returns 0 results', async () => { createComponent({ queryHandler: jest.fn().mockResolvedValue({ diff --git a/spec/frontend/vue_shared/components/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer_spec.js index 758068379de..094d8d42a47 100644 --- a/spec/frontend/vue_shared/components/source_viewer_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer_spec.js @@ -1,27 +1,35 @@ import hljs from 'highlight.js/lib/core'; +import Vue, { nextTick } from 'vue'; +import VueRouter from 'vue-router'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SourceViewer from '~/vue_shared/components/source_viewer.vue'; import LineNumbers from '~/vue_shared/components/line_numbers.vue'; import waitForPromises from 'helpers/wait_for_promises'; jest.mock('highlight.js/lib/core'); +Vue.use(VueRouter); +const router = new VueRouter(); describe('Source Viewer component', () => { let wrapper; const content = `// Some source code`; - const highlightedContent = `<span data-testid='test-highlighted'>${content}</span>`; + const highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`; const language = 'javascript'; hljs.highlight.mockImplementation(() => ({ value: highlightedContent })); hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent })); const createComponent = async (props = {}) => { - wrapper = shallowMountExtended(SourceViewer, { propsData: { content, language, ...props } }); + wrapper = shallowMountExtended(SourceViewer, { + router, + propsData: { content, language, ...props }, + }); await waitForPromises(); }; const findLineNumbers = () => wrapper.findComponent(LineNumbers); const findHighlightedContent = () => wrapper.findByTestId('test-highlighted'); + const findFirstLine = () => wrapper.find('#LC1'); beforeEach(() => createComponent()); @@ -56,4 +64,39 @@ describe('Source Viewer component', () => { expect(findHighlightedContent().exists()).toBe(true); }); }); + + describe('selecting a line', () => { + let firstLine; + let firstLineElement; + + beforeEach(() => { + firstLine = findFirstLine(); + firstLineElement = firstLine.element; + + jest.spyOn(firstLineElement, 'scrollIntoView'); + jest.spyOn(firstLineElement.classList, 'add'); + jest.spyOn(firstLineElement.classList, 'remove'); + }); + + it('adds the highlight (hll) class', async () => { + wrapper.vm.$router.push('#LC1'); + await nextTick(); + + expect(firstLineElement.classList.add).toHaveBeenCalledWith('hll'); + }); + + it('removes the highlight (hll) class from a previously highlighted line', async () => { + wrapper.vm.$router.push('#LC2'); + await nextTick(); + + expect(firstLineElement.classList.remove).toHaveBeenCalledWith('hll'); + }); + + it('scrolls the line into view', () => { + expect(firstLineElement.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'center', + }); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js index 92938b2717f..659d93d6597 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -1,11 +1,18 @@ -import { shallowMount } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; +import { nextTick } from 'vue'; + import ActionsButton from '~/vue_shared/components/actions_button.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; +import { stubComponent } from 'helpers/stub_component'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; + const TEST_EDIT_URL = '/gitlab-test/test/-/edit/main/'; const TEST_WEB_IDE_URL = '/-/ide/project/gitlab-test/test/edit/main/-/'; const TEST_GITPOD_URL = 'https://gitpod.test/'; +const TEST_USER_PREFERENCES_GITPOD_PATH = '/-/profile/preferences#user_gitpod_enabled'; +const TEST_USER_PROFILE_ENABLE_GITPOD_PATH = '/-/profile?user%5Bgitpod_enabled%5D=true'; const ACTION_EDIT = { href: TEST_EDIT_URL, @@ -54,21 +61,31 @@ const ACTION_GITPOD = { }; const ACTION_GITPOD_ENABLE = { ...ACTION_GITPOD, - href: '#modal-enable-gitpod', + href: undefined, handle: expect.any(Function), }; describe('Web IDE link component', () => { let wrapper; - function createComponent(props) { - wrapper = shallowMount(WebIdeLink, { + function createComponent(props, mountFn = shallowMountExtended) { + wrapper = mountFn(WebIdeLink, { propsData: { editUrl: TEST_EDIT_URL, webIdeUrl: TEST_WEB_IDE_URL, gitpodUrl: TEST_GITPOD_URL, ...props, }, + stubs: { + GlModal: stubComponent(GlModal, { + template: ` + <div> + <slot name="modal-title"></slot> + <slot></slot> + <slot name="modal-footer"></slot> + </div>`, + }), + }, }); } @@ -78,6 +95,7 @@ describe('Web IDE link component', () => { const findActionsButton = () => wrapper.find(ActionsButton); const findLocalStorageSync = () => wrapper.find(LocalStorageSync); + const findModal = () => wrapper.findComponent(GlModal); it.each([ { @@ -97,19 +115,68 @@ describe('Web IDE link component', () => { expectedActions: [ACTION_WEB_IDE_CONFIRM_FORK, ACTION_EDIT_CONFIRM_FORK], }, { - props: { showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: true }, + props: { + showWebIdeButton: false, + showGitpodButton: true, + userPreferencesGitpodPath: TEST_USER_PREFERENCES_GITPOD_PATH, + userProfileEnableGitpodPath: TEST_USER_PROFILE_ENABLE_GITPOD_PATH, + gitpodEnabled: true, + }, expectedActions: [ACTION_EDIT, ACTION_GITPOD], }, { - props: { showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: false }, + props: { + showWebIdeButton: false, + showGitpodButton: true, + userPreferencesGitpodPath: TEST_USER_PREFERENCES_GITPOD_PATH, + gitpodEnabled: true, + }, + expectedActions: [ACTION_EDIT], + }, + { + props: { + showWebIdeButton: false, + showGitpodButton: true, + userProfileEnableGitpodPath: TEST_USER_PROFILE_ENABLE_GITPOD_PATH, + gitpodEnabled: true, + }, + expectedActions: [ACTION_EDIT], + }, + { + props: { + showWebIdeButton: false, + showGitpodButton: true, + gitpodEnabled: true, + }, + expectedActions: [ACTION_EDIT], + }, + { + props: { + showWebIdeButton: false, + showGitpodButton: true, + userPreferencesGitpodPath: TEST_USER_PREFERENCES_GITPOD_PATH, + userProfileEnableGitpodPath: TEST_USER_PROFILE_ENABLE_GITPOD_PATH, + gitpodEnabled: false, + }, expectedActions: [ACTION_EDIT, ACTION_GITPOD_ENABLE], }, { - props: { showGitpodButton: true, gitpodEnabled: false }, + props: { + showGitpodButton: true, + userPreferencesGitpodPath: TEST_USER_PREFERENCES_GITPOD_PATH, + userProfileEnableGitpodPath: TEST_USER_PROFILE_ENABLE_GITPOD_PATH, + gitpodEnabled: false, + }, expectedActions: [ACTION_WEB_IDE, ACTION_EDIT, ACTION_GITPOD_ENABLE], }, { - props: { showEditButton: false, showGitpodButton: true, gitpodText: 'Test Gitpod' }, + props: { + showEditButton: false, + showGitpodButton: true, + userPreferencesGitpodPath: TEST_USER_PREFERENCES_GITPOD_PATH, + userProfileEnableGitpodPath: TEST_USER_PROFILE_ENABLE_GITPOD_PATH, + gitpodText: 'Test Gitpod', + }, expectedActions: [ACTION_WEB_IDE, { ...ACTION_GITPOD_ENABLE, text: 'Test Gitpod' }], }, { @@ -128,6 +195,8 @@ describe('Web IDE link component', () => { showEditButton: false, showWebIdeButton: true, showGitpodButton: true, + userPreferencesGitpodPath: TEST_USER_PREFERENCES_GITPOD_PATH, + userProfileEnableGitpodPath: TEST_USER_PROFILE_ENABLE_GITPOD_PATH, gitpodEnabled: true, }); }); @@ -174,7 +243,7 @@ describe('Web IDE link component', () => { ])( 'emits the correct event when an action handler is called', async ({ props, expectedEventPayload }) => { - createComponent({ ...props, needsToFork: true }); + createComponent({ ...props, needsToFork: true, disableForkModal: true }); findActionsButton().props('actions')[0].handle(); @@ -182,4 +251,72 @@ describe('Web IDE link component', () => { }, ); }); + + describe('when Gitpod is not enabled', () => { + it('renders closed modal to enable Gitpod', () => { + createComponent({ + showGitpodButton: true, + userPreferencesGitpodPath: TEST_USER_PREFERENCES_GITPOD_PATH, + userProfileEnableGitpodPath: TEST_USER_PROFILE_ENABLE_GITPOD_PATH, + gitpodEnabled: false, + }); + + const modal = findModal(); + + expect(modal.exists()).toBe(true); + expect(modal.props()).toMatchObject({ + visible: false, + modalId: 'enable-gitpod-modal', + size: 'sm', + title: WebIdeLink.i18n.modal.title, + actionCancel: { + text: WebIdeLink.i18n.modal.actionCancelText, + }, + actionPrimary: { + text: WebIdeLink.i18n.modal.actionPrimaryText, + attributes: { + variant: 'confirm', + category: 'primary', + href: TEST_USER_PROFILE_ENABLE_GITPOD_PATH, + 'data-method': 'put', + }, + }, + }); + }); + + it('opens modal when `Gitpod` action is clicked', async () => { + const gitpodText = 'Open in Gitpod'; + + createComponent( + { + showGitpodButton: true, + userPreferencesGitpodPath: TEST_USER_PREFERENCES_GITPOD_PATH, + userProfileEnableGitpodPath: TEST_USER_PROFILE_ENABLE_GITPOD_PATH, + gitpodEnabled: false, + gitpodText, + }, + mountExtended, + ); + + findLocalStorageSync().vm.$emit('input', ACTION_GITPOD.key); + + await nextTick(); + await wrapper.findByRole('button', { name: gitpodText }).trigger('click'); + + expect(findModal().props('visible')).toBe(true); + }); + }); + + describe('when Gitpod is enabled', () => { + it('does not render modal', () => { + createComponent({ + showGitpodButton: true, + userPreferencesGitpodPath: TEST_USER_PREFERENCES_GITPOD_PATH, + userProfileEnableGitpodPath: TEST_USER_PROFILE_ENABLE_GITPOD_PATH, + gitpodEnabled: true, + }); + + expect(findModal().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/vue_shared/directives/track_event_spec.js b/spec/frontend/vue_shared/directives/track_event_spec.js index d7d7f4edc3f..b3f94d0242a 100644 --- a/spec/frontend/vue_shared/directives/track_event_spec.js +++ b/spec/frontend/vue_shared/directives/track_event_spec.js @@ -38,6 +38,8 @@ describe('Error Tracking directive', () => { label: 'Trackable Info', }; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ trackingOptions }); const { category, action, label, property, value } = trackingOptions; diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js index 5979a65e3cd..14e93108447 100644 --- a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js @@ -98,6 +98,8 @@ describe('IssuableListRoot', () => { await wrapper.vm.$nextTick(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ checkedIssuables, }); @@ -111,6 +113,8 @@ describe('IssuableListRoot', () => { describe('bulkEditIssuables', () => { it('returns array of issuables which have `checked` set to true within checkedIssuables map', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ checkedIssuables: mockCheckedIssuables, }); @@ -180,6 +184,8 @@ describe('IssuableListRoot', () => { describe('issuableChecked', () => { it('returns boolean value representing checked status of issuable item', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ checkedIssuables: { [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] }, diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_tabs_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_tabs_spec.js index 8c22b67bdbe..5723e2da586 100644 --- a/spec/frontend/vue_shared/issuable/list/components/issuable_tabs_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_tabs_spec.js @@ -1,5 +1,6 @@ import { GlTab, GlBadge } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import { setLanguage } from 'helpers/locale_helper'; import IssuableTabs from '~/vue_shared/issuable/list/components/issuable_tabs.vue'; @@ -27,10 +28,12 @@ describe('IssuableTabs', () => { let wrapper; beforeEach(() => { + setLanguage('en'); wrapper = createComponent(); }); afterEach(() => { + setLanguage(null); wrapper.destroy(); }); @@ -71,7 +74,7 @@ describe('IssuableTabs', () => { // 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(0).text()).toBe('5,000'); expect(badges.at(1).text()).toBe(`${mockIssuableListProps.tabCounts.closed}`); }); diff --git a/spec/frontend/vue_shared/issuable/list/mock_data.js b/spec/frontend/vue_shared/issuable/list/mock_data.js index e2fa99f7cc9..cfc7937b412 100644 --- a/spec/frontend/vue_shared/issuable/list/mock_data.js +++ b/spec/frontend/vue_shared/issuable/list/mock_data.js @@ -133,7 +133,7 @@ export const mockTabs = [ ]; export const mockTabCounts = { - opened: 5, + opened: 5000, closed: 0, all: undefined, }; diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js index 1fcf37a0477..cb418371760 100644 --- a/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js @@ -84,6 +84,8 @@ describe('IssuableTitle', () => { }); it('renders sticky header when `stickyTitleVisible` prop is true', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ stickyTitleVisible: true, }); diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js index 02795751f33..ea26b2b4fb3 100644 --- a/spec/frontend/work_items/pages/work_item_root_spec.js +++ b/spec/frontend/work_items/pages/work_item_root_spec.js @@ -3,6 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import WorkItemsRoot from '~/work_items/pages/work_item_root.vue'; @@ -15,6 +16,7 @@ Vue.use(VueApollo); const WORK_ITEM_ID = '1'; describe('Work items root component', () => { + const mockUpdatedTitle = 'Updated title'; let wrapper; let fakeApollo; @@ -53,7 +55,6 @@ describe('Work items root component', () => { it('updates the title when it is edited', async () => { createComponent(); jest.spyOn(wrapper.vm.$apollo, 'mutate'); - const mockUpdatedTitle = 'Updated title'; await findTitle().vm.$emit('title-changed', mockUpdatedTitle); @@ -91,4 +92,32 @@ describe('Work items root component', () => { expect(findTitle().exists()).toBe(false); }); + + describe('tracking', () => { + let trackingSpy; + + beforeEach(() => { + trackingSpy = mockTracking('_category_', undefined, jest.spyOn); + + createComponent(); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('tracks item title updates', async () => { + await findTitle().vm.$emit('title-changed', mockUpdatedTitle); + + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith('workItems:show', undefined, { + action: 'updated_title', + category: 'workItems:show', + label: 'item_title', + property: '[type_work_item]', + }); + }); + }); }); |