diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-07-20 18:40:28 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-07-20 18:40:28 +0300 |
commit | b595cb0c1dec83de5bdee18284abe86614bed33b (patch) | |
tree | 8c3d4540f193c5ff98019352f554e921b3a41a72 /spec/frontend | |
parent | 2f9104a328fc8a4bddeaa4627b595166d24671d0 (diff) |
Add latest changes from gitlab-org/gitlab@15-2-stable-eev15.2.0-rc42
Diffstat (limited to 'spec/frontend')
371 files changed, 12134 insertions, 7661 deletions
diff --git a/spec/frontend/__helpers__/init_vue_mr_page_helper.js b/spec/frontend/__helpers__/init_vue_mr_page_helper.js index 6b719a32480..83ed0a869dc 100644 --- a/spec/frontend/__helpers__/init_vue_mr_page_helper.js +++ b/spec/frontend/__helpers__/init_vue_mr_page_helper.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import initMRPage from '~/mr_notes'; -import diffFileMockData from '../diffs/mock_data/diff_file'; +import { getDiffFileMock } from '../diffs/mock_data/diff_file'; import { userDataMock, notesDataMock, noteableDataMock } from '../notes/mock_data'; export default function initVueMRPage() { @@ -39,7 +39,7 @@ export default function initVueMRPage() { const mock = new MockAdapter(axios); mock.onGet(diffsAppEndpoint).reply(200, { branch_name: 'foo', - diff_files: [diffFileMockData], + diff_files: [getDiffFileMock()], }); initMRPage(); diff --git a/spec/frontend/__helpers__/matchers/index.js b/spec/frontend/__helpers__/matchers/index.js index 9b83ced10e1..5da6676cdc1 100644 --- a/spec/frontend/__helpers__/matchers/index.js +++ b/spec/frontend/__helpers__/matchers/index.js @@ -2,3 +2,4 @@ export * from './to_have_sprite_icon'; export * from './to_have_tracking_attributes'; export * from './to_match_interpolated_text'; export * from './to_validate_json_schema'; +export * from './to_match_expected_for_markdown'; diff --git a/spec/frontend/__helpers__/matchers/to_match_expected_for_markdown.js b/spec/frontend/__helpers__/matchers/to_match_expected_for_markdown.js new file mode 100644 index 00000000000..829f6ba9770 --- /dev/null +++ b/spec/frontend/__helpers__/matchers/to_match_expected_for_markdown.js @@ -0,0 +1,60 @@ +export function toMatchExpectedForMarkdown( + received, + deserializationTarget, + name, + markdown, + errMsg, + expected, +) { + const options = { + comment: `Markdown deserialization to ${deserializationTarget}`, + isNot: this.isNot, + promise: this.promise, + }; + + const EXPECTED_LABEL = 'Expected'; + const RECEIVED_LABEL = 'Received'; + const isExpand = (expand) => expand !== false; + const forMarkdownName = `for Markdown example '${name}':\n${markdown}`; + const matcherName = `toMatchExpected${ + deserializationTarget === 'HTML' ? 'Html' : 'Json' + }ForMarkdown`; + + let pass; + + // If both expected and received are deserialization errors, force pass = true, + // because the actual error messages can vary across environments and cause + // false failures (e.g. due to jest '--coverage' being passed in CI). + const errMsgRegExp = new RegExp(errMsg); + const errMsgRegExp2 = new RegExp(errMsg); + + if (errMsgRegExp.test(expected) && errMsgRegExp2.test(received)) { + pass = true; + } else { + pass = received === expected; + } + + const message = pass + ? () => + // eslint-disable-next-line prefer-template + this.utils.matcherHint(matcherName, undefined, undefined, options) + + '\n\n' + + `Expected HTML to NOT match:\n${expected}\n\n${forMarkdownName}` + : () => { + return ( + // eslint-disable-next-line prefer-template + this.utils.matcherHint(matcherName, undefined, undefined, options) + + '\n\n' + + this.utils.printDiffOrStringify( + expected, + received, + EXPECTED_LABEL, + RECEIVED_LABEL, + isExpand(this.expand), + ) + + `\n\n${forMarkdownName}` + ); + }; + + return { actual: received, expected, message, name: matcherName, pass }; +} diff --git a/spec/frontend/__helpers__/mock_user_callout_dismisser.js b/spec/frontend/__helpers__/mock_user_callout_dismisser.js index 652f36028dc..f115e2289af 100644 --- a/spec/frontend/__helpers__/mock_user_callout_dismisser.js +++ b/spec/frontend/__helpers__/mock_user_callout_dismisser.js @@ -1,3 +1,5 @@ +import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; + /** * Mock factory for the UserCalloutDismisser component. * @param {slotProps} The slot props to pass to the default slot content. @@ -6,11 +8,24 @@ export const makeMockUserCalloutDismisser = ({ dismiss = () => {}, shouldShowCallout = true, + isLoadingQuery = false, } = {}) => ({ + props: UserCalloutDismisser.props, + data() { + return { + isLoadingQuery, + shouldShowCallout, + dismiss, + }; + }, + mounted() { + this.$emit('queryResult', { shouldShowCallout }); + }, render() { return this.$scopedSlots.default({ dismiss, shouldShowCallout, + isLoadingQuery, }); }, }); diff --git a/spec/frontend/__helpers__/performance.js b/spec/frontend/__helpers__/performance.js new file mode 100644 index 00000000000..3bdf163c22b --- /dev/null +++ b/spec/frontend/__helpers__/performance.js @@ -0,0 +1,8 @@ +// FIXME(vslobodin): Remove this stub once we have migrated to Jest 28. +// NOTE: Do not try to optimize these stubs as Jest 27 overwrites +// the "global.performance" object in every suite where fake timers are enabled. +export const stubPerformanceWebAPI = () => { + global.performance.getEntriesByName = () => []; + global.performance.mark = () => {}; + global.performance.measure = () => {}; +}; diff --git a/spec/frontend/__helpers__/set_window_location_helper.js b/spec/frontend/__helpers__/set_window_location_helper.js index 573a089f111..d81c502b337 100644 --- a/spec/frontend/__helpers__/set_window_location_helper.js +++ b/spec/frontend/__helpers__/set_window_location_helper.js @@ -30,7 +30,7 @@ * // window.location.href is now 'http://test.host/a/b/foo.html?bar=1#qux * * Both approaches also automatically update the rest of the properties on - * `window.locaton`. For instance: + * `window.location`. For instance: * * setWindowLocation('http://test.host/a/b/foo.html?bar=1#qux'); * // window.location.origin is now 'http://test.host' diff --git a/spec/frontend/__helpers__/shared_test_setup.js b/spec/frontend/__helpers__/shared_test_setup.js index 011e1142c76..4d6486544ca 100644 --- a/spec/frontend/__helpers__/shared_test_setup.js +++ b/spec/frontend/__helpers__/shared_test_setup.js @@ -15,6 +15,7 @@ import '~/commons/bootstrap'; // This module has some fairly decent visual test coverage in it's own repository. jest.mock('@gitlab/favicon-overlay'); +jest.mock('~/lib/utils/axios_utils', () => jest.requireActual('helpers/mocks/axios_utils')); process.on('unhandledRejection', global.promiseRejectionHandler); diff --git a/spec/frontend/__helpers__/web_worker_fake.js b/spec/frontend/__helpers__/web_worker_fake.js index 041a9bd8540..fb37e41a853 100644 --- a/spec/frontend/__helpers__/web_worker_fake.js +++ b/spec/frontend/__helpers__/web_worker_fake.js @@ -14,8 +14,7 @@ const createRelativeRequire = (filename) => { const rel = path.relative(__dirname, path.dirname(filename)); const base = path.resolve(__dirname, rel); - // reason: Dynamic require should be fine here since the code is dynamically evaluated anyways. - // eslint-disable-next-line import/no-dynamic-require, global-require + // eslint-disable-next-line global-require return (pathArg) => require(transformRequirePath(base, pathArg)); }; diff --git a/spec/frontend/access_tokens/components/access_token_table_app_spec.js b/spec/frontend/access_tokens/components/access_token_table_app_spec.js index b45abe418e4..6013fa3ec39 100644 --- a/spec/frontend/access_tokens/components/access_token_table_app_spec.js +++ b/spec/frontend/access_tokens/components/access_token_table_app_spec.js @@ -1,4 +1,4 @@ -import { GlPagination, GlTable } from '@gitlab/ui'; +import { GlButton, GlPagination, GlTable } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import AccessTokenTableApp from '~/access_tokens/components/access_token_table_app.vue'; @@ -164,8 +164,8 @@ describe('~/access_tokens/components/access_token_table_app', () => { expect(cells.at(3).text()).toBe(__('Never')); expect(cells.at(4).text()).toBe(__('Never')); expect(cells.at(5).text()).toBe('Maintainer'); - let anchor = cells.at(6).find('a'); - expect(anchor.attributes()).toMatchObject({ + let button = cells.at(6).findComponent(GlButton); + expect(button.attributes()).toMatchObject({ 'aria-label': __('Revoke'), 'data-qa-selector': __('revoke_button'), href: '/-/profile/personal_access_tokens/1/revoke', @@ -176,8 +176,7 @@ describe('~/access_tokens/components/access_token_table_app', () => { { accessTokenType }, ), }); - - expect(anchor.classes()).toContain('btn-danger-secondary'); + expect(button.props('category')).toBe('tertiary'); // Second row expect(cells.at(7).text()).toBe('b'); @@ -186,9 +185,9 @@ describe('~/access_tokens/components/access_token_table_app', () => { expect(cells.at(10).text()).not.toBe(__('Never')); expect(cells.at(11).text()).toBe(__('Expired')); expect(cells.at(12).text()).toBe('Maintainer'); - anchor = cells.at(13).find('a'); - expect(anchor.attributes('href')).toBe('/-/profile/personal_access_tokens/2/revoke'); - expect(anchor.classes()).toEqual(['btn', 'btn-danger', 'btn-md', 'gl-button', 'btn-icon']); + button = cells.at(13).findComponent(GlButton); + expect(button.attributes('href')).toBe('/-/profile/personal_access_tokens/2/revoke'); + expect(button.props('category')).toBe('tertiary'); }); it('sorts rows alphabetically', async () => { diff --git a/spec/frontend/access_tokens/components/projects_field_spec.js b/spec/frontend/access_tokens/components/projects_field_spec.js index a9e0799d114..1c4fe7bb168 100644 --- a/spec/frontend/access_tokens/components/projects_field_spec.js +++ b/spec/frontend/access_tokens/components/projects_field_spec.js @@ -1,3 +1,4 @@ +import { nextTick } from 'vue'; import { within, fireEvent } from '@testing-library/dom'; import { mount } from '@vue/test-utils'; import ProjectsField from '~/access_tokens/components/projects_field.vue'; @@ -118,11 +119,10 @@ describe('ProjectsField', () => { }); describe('when radio is changed back to "All projects"', () => { - beforeEach(() => { - fireEvent.click(findAllProjectsRadio()); - }); + it('removes the hidden input value', async () => { + fireEvent.change(findAllProjectsRadio()); + await nextTick(); - it('removes the hidden input value', () => { expect(findHiddenInput().attributes('value')).toBe(''); }); }); diff --git a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap index 3fdbacb6efa..2c2151bfb41 100644 --- a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap +++ b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap @@ -25,7 +25,7 @@ exports[`AddContextCommitsModal renders modal with 2 tabs 1`] = ` > <div - class="mt-2" + class="gl-mt-3" > <gl-search-box-by-type-stub clearbuttontitle="Clear" diff --git a/spec/frontend/admin/analytics/devops_score/components/devops_score_spec.js b/spec/frontend/admin/analytics/devops_score/components/devops_score_spec.js index 14f94e671a4..d6c5c5f963a 100644 --- a/spec/frontend/admin/analytics/devops_score/components/devops_score_spec.js +++ b/spec/frontend/admin/analytics/devops_score/components/devops_score_spec.js @@ -52,7 +52,7 @@ describe('DevopsScore', () => { it('contains a link to the feature documentation', () => { expect(findDocsLink().exists()).toBe(true); expect(findDocsLink().attributes('href')).toBe( - '/help/user/admin_area/analytics/dev_ops_report', + '/help/user/admin_area/analytics/dev_ops_reports', ); }); }); diff --git a/spec/frontend/admin/deploy_keys/components/table_spec.js b/spec/frontend/admin/deploy_keys/components/table_spec.js index 49bda7100fb..a18506c0916 100644 --- a/spec/frontend/admin/deploy_keys/components/table_spec.js +++ b/spec/frontend/admin/deploy_keys/components/table_spec.js @@ -27,6 +27,7 @@ describe('DeployKeysTable', () => { const deployKey = responseBody[0]; const deployKey2 = responseBody[1]; + const deployKeyWithoutMd5Fingerprint = responseBody[2]; const createComponent = (provide = {}) => { wrapper = mountExtended(DeployKeysTable, { @@ -57,9 +58,10 @@ describe('DeployKeysTable', () => { const timeAgoTooltip = findTimeAgoTooltip(expectedRowIndex); expect(wrapper.findByText(expectedDeployKey.title).exists()).toBe(true); - expect(wrapper.findByText(expectedDeployKey.fingerprint, { selector: 'code' }).exists()).toBe( - true, - ); + + expect( + wrapper.findByText(expectedDeployKey.fingerprint_sha256, { selector: 'span' }).exists(), + ).toBe(true); expect(timeAgoTooltip.exists()).toBe(true); expect(timeAgoTooltip.props('time')).toBe(expectedDeployKey.created_at); expect(editButton.exists()).toBe(true); @@ -67,6 +69,13 @@ describe('DeployKeysTable', () => { expect(findRemoveButton(expectedRowIndex).exists()).toBe(true); }; + const expectDeployKeyWithFingerprintIsRendered = (expectedDeployKey, expectedRowIndex) => { + expect(wrapper.findByText(expectedDeployKey.fingerprint, { selector: 'span' }).exists()).toBe( + true, + ); + expectDeployKeyIsRendered(expectedDeployKey, expectedRowIndex); + }; + const itRendersTheEmptyState = () => { it('renders empty state', () => { const emptyState = wrapper.findComponent(GlEmptyState); @@ -127,8 +136,12 @@ describe('DeployKeysTable', () => { }); it('renders deploy keys in table', () => { - expectDeployKeyIsRendered(deployKey, 0); - expectDeployKeyIsRendered(deployKey2, 1); + expectDeployKeyWithFingerprintIsRendered(deployKey, 0); + expectDeployKeyWithFingerprintIsRendered(deployKey2, 1); + }); + + it('renders deploy keys that do not have an MD5 fingerprint', () => { + expectDeployKeyIsRendered(deployKeyWithoutMd5Fingerprint, 2); }); describe('when delete button is clicked', () => { @@ -157,7 +170,7 @@ describe('DeployKeysTable', () => { beforeEach(() => { Api.deployKeys.mockResolvedValueOnce({ data: [deployKey], - headers: { 'x-total': '2' }, + headers: { 'x-total': '3' }, }); createComponent(); @@ -179,7 +192,7 @@ describe('DeployKeysTable', () => { describe('when pagination is changed', () => { it('calls API with `page` parameter', async () => { const pagination = findPagination(); - expectDeployKeyIsRendered(deployKey, 0); + expectDeployKeyWithFingerprintIsRendered(deployKey, 0); Api.deployKeys.mockResolvedValue({ data: [deployKey2], @@ -199,7 +212,7 @@ describe('DeployKeysTable', () => { page: 2, public: true, }); - expectDeployKeyIsRendered(deployKey2, 0); + expectDeployKeyWithFingerprintIsRendered(deployKey2, 0); }); }); }); diff --git a/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js b/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js index 5b4f954b672..31a0c2b07e4 100644 --- a/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js +++ b/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js @@ -1,6 +1,6 @@ import { GlButton, GlModal } from '@gitlab/ui'; -import { within, fireEvent } from '@testing-library/dom'; -import { shallowMount, mount } from '@vue/test-utils'; +import { within } from '@testing-library/dom'; +import { shallowMount, mount, createWrapper } from '@vue/test-utils'; import { stubComponent } from 'helpers/stub_component'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import SignupForm from '~/pages/admin/application_settings/general/components/signup_form.vue'; @@ -121,7 +121,7 @@ describe('Signup Form', () => { describe('when user clicks on file radio', () => { beforeEach(() => { - fireEvent.click(findDenyListFileRadio()); + createWrapper(findDenyListFileRadio()).setChecked(true); }); it('has raw list not selected', () => { @@ -165,7 +165,7 @@ describe('Signup Form', () => { describe('when user clicks on raw list radio', () => { beforeEach(() => { - fireEvent.click(findDenyListRawRadio()); + createWrapper(findDenyListRawRadio()).setChecked(true); }); it('has raw list selected', () => { diff --git a/spec/frontend/admin/signup_restrictions/mock_data.js b/spec/frontend/admin/signup_restrictions/mock_data.js index 135fc8caae0..9e001e122a4 100644 --- a/spec/frontend/admin/signup_restrictions/mock_data.js +++ b/spec/frontend/admin/signup_restrictions/mock_data.js @@ -18,6 +18,10 @@ export const rawMockData = { emailRestrictions: 'user1@domain.com, user2@domain.com', afterSignUpText: 'Congratulations on your successful sign-up!', pendingUserCount: '0', + passwordNumberRequired: 'true', + passwordLowercaseRequired: 'true', + passwordUppercaseRequired: 'true', + passwordSymbolRequired: 'true', }; export const mockData = { @@ -40,4 +44,8 @@ export const mockData = { emailRestrictions: 'user1@domain.com, user2@domain.com', afterSignUpText: 'Congratulations on your successful sign-up!', pendingUserCount: '0', + passwordNumberRequired: true, + passwordLowercaseRequired: true, + passwordUppercaseRequired: true, + passwordSymbolRequired: true, }; diff --git a/spec/frontend/admin/signup_restrictions/utils_spec.js b/spec/frontend/admin/signup_restrictions/utils_spec.js index fd5c4c3317b..f07e14430f9 100644 --- a/spec/frontend/admin/signup_restrictions/utils_spec.js +++ b/spec/frontend/admin/signup_restrictions/utils_spec.js @@ -14,6 +14,10 @@ describe('utils', () => { 'domainDenylistEnabled', 'denylistTypeRawSelected', 'emailRestrictionsEnabled', + 'passwordNumberRequired', + 'passwordLowercaseRequired', + 'passwordUppercaseRequired', + 'passwordSymbolRequired', ], }), ).toEqual(mockData); diff --git a/spec/frontend/admin/statistics_panel/components/app_spec.js b/spec/frontend/admin/statistics_panel/components/app_spec.js index 3cfb6feeb86..bac542e72fb 100644 --- a/spec/frontend/admin/statistics_panel/components/app_spec.js +++ b/spec/frontend/admin/statistics_panel/components/app_spec.js @@ -64,7 +64,7 @@ describe('Admin statistics app', () => { createComponent(); expect(findStats(index).text()).toContain(label); - expect(findStats(index).text()).toContain(count); + expect(findStats(index).text()).toContain(count.toString()); }); }); }); diff --git a/spec/frontend/admin/users/components/user_actions_spec.js b/spec/frontend/admin/users/components/user_actions_spec.js index b90a30b5b89..e04c43ae3f2 100644 --- a/spec/frontend/admin/users/components/user_actions_spec.js +++ b/spec/frontend/admin/users/components/user_actions_spec.js @@ -77,12 +77,6 @@ describe('AdminUserActions component', () => { expect(findActionsDropdown().exists()).toBe(true); }); - it('renders the tooltip', () => { - const tooltip = getBinding(findActionsDropdown().element, 'gl-tooltip'); - - expect(tooltip.value).toBe(I18N_USER_ACTIONS.userAdministration); - }); - describe('when there are actions that require confirmation', () => { beforeEach(() => { initComponent({ actions: CONFIRMATION_ACTIONS }); diff --git a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap index ec5b6a5597b..4693d5a47e4 100644 --- a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap +++ b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap @@ -17,6 +17,7 @@ exports[`Alert integration settings form default state should match the default <gl-form-checkbox-stub checked="true" data-qa-selector="create_issue_checkbox" + id="2" > <span> Create an incident. Incidents are created for each alert triggered. @@ -87,7 +88,9 @@ exports[`Alert integration settings form default state should match the default labeldescription="" optionaltext="(optional)" > - <gl-form-checkbox-stub> + <gl-form-checkbox-stub + id="3" + > <span> Send a single email notification to Owners and Maintainers for new alerts. </span> @@ -101,6 +104,7 @@ exports[`Alert integration settings form default state should match the default > <gl-form-checkbox-stub checked="true" + id="4" > <span> Automatically close associated incident when a recovery alert notification resolves an alert 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 018303fcae7..7d9d2875cf8 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js @@ -265,7 +265,6 @@ describe('AlertsSettingsForm', () => { }); it('should not allow a user to test invalid JSON', async () => { - jest.useFakeTimers(); await findJsonTextArea().setValue('Invalid JSON'); jest.runAllTimers(); @@ -278,7 +277,6 @@ describe('AlertsSettingsForm', () => { }); it('should allow for the form to be automatically saved if the test payload is successfully submitted', async () => { - jest.useFakeTimers(); await findJsonTextArea().setValue('{ "value": "value" }'); jest.runAllTimers(); diff --git a/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap b/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap index 2691e11e616..ba8215f4e00 100644 --- a/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap +++ b/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap @@ -7,6 +7,7 @@ exports[`Keep latest artifact checkbox when application keep latest artifact set <b-form-checkbox-stub checked="true" class="gl-form-checkbox" + id="4" value="true" > <strong diff --git a/spec/frontend/batch_comments/components/draft_note_spec.js b/spec/frontend/batch_comments/components/draft_note_spec.js index 6a997ebaaa8..ccca4a2c3e9 100644 --- a/spec/frontend/batch_comments/components/draft_note_spec.js +++ b/spec/frontend/batch_comments/components/draft_note_spec.js @@ -33,13 +33,16 @@ describe('Batch comments draft note component', () => { const findSubmitReviewButton = () => wrapper.findComponent(PublishButton); const findAddCommentButton = () => wrapper.findComponent(GlButton); - const createComponent = (propsData = { draft }) => { + const createComponent = (propsData = { draft }, glFeatures = {}) => { wrapper = shallowMount(DraftNote, { store, propsData, stubs: { NoteableNote: NoteableNoteStub, }, + provide: { + glFeatures, + }, }); jest.spyOn(wrapper.vm.$store, 'dispatch').mockImplementation(); @@ -96,6 +99,12 @@ describe('Batch comments draft note component', () => { expect(publishNowButton.props().disabled).toBe(true); expect(publishNowButton.props().loading).toBe(false); }); + + it('hides button when mr_review_submit_comment is enabled', () => { + createComponent({ draft }, { mrReviewSubmitComment: true }); + + expect(findAddCommentButton().exists()).toBe(false); + }); }); describe('submit review', () => { diff --git a/spec/frontend/batch_comments/components/preview_dropdown_spec.js b/spec/frontend/batch_comments/components/preview_dropdown_spec.js index bf3bbf4de26..079b64225e4 100644 --- a/spec/frontend/batch_comments/components/preview_dropdown_spec.js +++ b/spec/frontend/batch_comments/components/preview_dropdown_spec.js @@ -1,8 +1,15 @@ import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { TEST_HOST } from 'helpers/test_constants'; +import { visitUrl } from '~/lib/utils/url_utility'; import PreviewDropdown from '~/batch_comments/components/preview_dropdown.vue'; +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn(), + setUrlParams: jest.requireActual('~/lib/utils/url_utility').setUrlParams, +})); + Vue.use(Vuex); let wrapper; @@ -27,6 +34,11 @@ function factory({ viewDiffsFileByFile = false, draftsCount = 1, sortedDrafts = actions: { scrollToDraft }, getters: { draftsCount: () => draftsCount, sortedDrafts: () => sortedDrafts }, }, + notes: { + getters: { + getNoteableData: () => ({ diff_head_sha: '123' }), + }, + }, }, }); @@ -67,5 +79,19 @@ describe('Batch comments preview dropdown', () => { expect(scrollToDraft).toHaveBeenCalledWith(expect.anything(), { id: 1 }); }); + + it('changes window location to navigate to commit', async () => { + factory({ + viewDiffsFileByFile: false, + sortedDrafts: [{ id: 1, position: { head_sha: '1234' } }], + }); + + wrapper.findByTestId('preview-item').vm.$emit('click'); + + await nextTick(); + + expect(scrollToDraft).not.toHaveBeenCalled(); + expect(visitUrl).toHaveBeenCalledWith(`${TEST_HOST}/?commit_id=1234#note_1`); + }); }); }); diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js index 172b510645d..9f50b12bac2 100644 --- a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js +++ b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js @@ -298,14 +298,18 @@ describe('Batch comments store actions', () => { const draft = { discussion_id: '1', id: '2', + file_path: 'lib/example.js', }; actions.scrollToDraft({ dispatch, rootGetters }, draft); - expect(dispatch.mock.calls[0]).toEqual([ - 'expandDiscussion', - { discussionId: '1' }, - { root: true }, + expect(dispatch.mock.calls).toEqual([ + [ + 'diffs/setFileCollapsedAutomatically', + { filePath: draft.file_path, collapsed: false }, + { root: true }, + ], + ['expandDiscussion', { discussionId: '1' }, { root: true }], ]); expect(window.mrTabs.tabShown).toHaveBeenCalledWith('diffs'); diff --git a/spec/frontend/behaviors/shortcuts/keybindings_spec.js b/spec/frontend/behaviors/shortcuts/keybindings_spec.js index 3ad44a16ae1..1f7e1b24e78 100644 --- a/spec/frontend/behaviors/shortcuts/keybindings_spec.js +++ b/spec/frontend/behaviors/shortcuts/keybindings_spec.js @@ -11,9 +11,7 @@ import { } from '~/behaviors/shortcuts/keybindings'; describe('~/behaviors/shortcuts/keybindings', () => { - beforeAll(() => { - useLocalStorageSpy(); - }); + useLocalStorageSpy(); const setupCustomizations = (customizationsAsString) => { localStorage.clear(); diff --git a/spec/frontend/blob/viewer/index_spec.js b/spec/frontend/blob/viewer/index_spec.js deleted file mode 100644 index b2559af182b..00000000000 --- a/spec/frontend/blob/viewer/index_spec.js +++ /dev/null @@ -1,189 +0,0 @@ -/* eslint-disable no-new */ - -import MockAdapter from 'axios-mock-adapter'; -import $ from 'jquery'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import { setTestTimeout } from 'helpers/timeout'; -import { BlobViewer } from '~/blob/viewer/index'; -import axios from '~/lib/utils/axios_utils'; - -const execImmediately = (callback) => { - callback(); -}; - -describe('Blob viewer', () => { - let blob; - let mock; - - const jQueryMock = { - tooltip: jest.fn(), - }; - - 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); - - loadHTMLFixture('blob/show_readme.html'); - $('#modal-upload-blob').remove(); - - mock.onGet(/blob\/.+\/README\.md/).reply(200, { - html: '<div>testing</div>', - }); - - blob = new BlobViewer(); - }); - - afterEach(() => { - mock.restore(); - window.location.hash = ''; - - resetHTMLFixture(); - }); - - it('loads source file after switching views', async () => { - document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click(); - - await axios.waitForAll(); - - expect( - document - .querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]') - .classList.contains('hidden'), - ).toBeFalsy(); - }); - - it('loads source file when line number is in hash', async () => { - window.location.hash = '#L1'; - - new BlobViewer(); - - await axios.waitForAll(); - - expect( - document - .querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]') - .classList.contains('hidden'), - ).toBeFalsy(); - }); - - it('doesnt reload file if already loaded', () => { - const asyncClick = async () => { - document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click(); - - await axios.waitForAll(); - }; - - return asyncClick() - .then(() => asyncClick()) - .then(() => { - expect(document.querySelector('.blob-viewer[data-type="simple"]').dataset.loaded).toBe( - 'true', - ); - }); - }); - - describe('copy blob button', () => { - let copyButton; - let copyButtonTooltip; - - beforeEach(() => { - copyButton = document.querySelector('.js-copy-blob-source-btn'); - copyButtonTooltip = document.querySelector('.js-copy-blob-source-btn-tooltip'); - }); - - it('disabled on load', () => { - expect(copyButton.classList.contains('disabled')).toBeTruthy(); - }); - - it('has tooltip when disabled', () => { - expect(copyButtonTooltip.getAttribute('title')).toBe( - 'Switch to the source to copy the file contents', - ); - }); - - it('is blurred when clicked and disabled', () => { - jest.spyOn(copyButton, 'blur').mockImplementation(() => {}); - - copyButton.click(); - - expect(copyButton.blur).toHaveBeenCalled(); - }); - - it('is not blurred when clicked and not disabled', () => { - jest.spyOn(copyButton, 'blur').mockImplementation(() => {}); - - copyButton.classList.remove('disabled'); - copyButton.click(); - - expect(copyButton.blur).not.toHaveBeenCalled(); - }); - - it('enables after switching to simple view', async () => { - document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click(); - - await axios.waitForAll(); - - expect(copyButton.classList.contains('disabled')).toBeFalsy(); - }); - - it('updates tooltip after switching to simple view', async () => { - document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click(); - - await axios.waitForAll(); - - expect(copyButtonTooltip.getAttribute('title')).toBe('Copy file contents'); - }); - }); - - describe('switchToViewer', () => { - it('removes active class from old viewer button', () => { - blob.switchToViewer('simple'); - - expect( - document.querySelector('.js-blob-viewer-switch-btn.active[data-viewer="rich"]'), - ).toBeNull(); - }); - - it('adds active class to new viewer button', () => { - const simpleBtn = document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]'); - - jest.spyOn(simpleBtn, 'blur').mockImplementation(() => {}); - - blob.switchToViewer('simple'); - - expect(simpleBtn.classList.contains('selected')).toBeTruthy(); - - expect(simpleBtn.blur).toHaveBeenCalled(); - }); - - it('makes request for initial view', () => { - expect(mock.history).toMatchObject({ - get: [{ url: expect.stringMatching(/README\.md\?.*viewer=rich/) }], - }); - }); - - describe.each` - views - ${['simple']} - ${['simple', 'rich']} - `('when view switches to $views', ({ views }) => { - beforeEach(async () => { - views.forEach((view) => blob.switchToViewer(view)); - await axios.waitForAll(); - }); - - it('sends 1 AJAX request for new view', async () => { - expect(mock.history).toMatchObject({ - get: [ - { url: expect.stringMatching(/README\.md\?.*viewer=rich/) }, - { url: expect.stringMatching(/README\.md\?.*viewer=simple/) }, - ], - }); - }); - }); - }); -}); diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js index aad89cf8261..17a5383a31e 100644 --- a/spec/frontend/boards/components/board_card_spec.js +++ b/spec/frontend/boards/components/board_card_spec.js @@ -6,7 +6,7 @@ import Vuex from 'vuex'; import BoardCard from '~/boards/components/board_card.vue'; import BoardCardInner from '~/boards/components/board_card_inner.vue'; import { inactiveId } from '~/boards/constants'; -import { mockLabelList, mockIssue } from '../mock_data'; +import { mockLabelList, mockIssue, DEFAULT_COLOR } from '../mock_data'; describe('Board card', () => { let wrapper; @@ -180,4 +180,40 @@ describe('Board card', () => { expect(wrapper.classes()).toContain('gl-cursor-grab'); }); }); + + describe('when Epic colors are enabled', () => { + it('applies the correct color', () => { + window.gon.features = { epicColorHighlight: true }; + createStore(); + mountComponent({ + item: { + ...mockIssue, + color: DEFAULT_COLOR, + }, + }); + + expect(wrapper.classes()).toEqual( + expect.arrayContaining(['gl-pl-4', 'gl-border-l-solid', 'gl-border-4']), + ); + expect(wrapper.attributes('style')).toContain(`border-color: ${DEFAULT_COLOR}`); + }); + }); + + describe('when Epic colors are not enabled', () => { + it('applies the correct color', () => { + window.gon.features = { epicColorHighlight: false }; + createStore(); + mountComponent({ + item: { + ...mockIssue, + color: DEFAULT_COLOR, + }, + }); + + expect(wrapper.classes()).not.toEqual( + expect.arrayContaining(['gl-pl-4', 'gl-border-l-solid', 'gl-border-4']), + ); + expect(wrapper.attributes('style')).toBeUndefined(); + }); + }); }); diff --git a/spec/frontend/boards/components/config_toggle_spec.js b/spec/frontend/boards/components/config_toggle_spec.js new file mode 100644 index 00000000000..47d4692453d --- /dev/null +++ b/spec/frontend/boards/components/config_toggle_spec.js @@ -0,0 +1,59 @@ +import Vuex from 'vuex'; +import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import ConfigToggle from '~/boards/components/config_toggle.vue'; +import eventHub from '~/boards/eventhub'; +import store from '~/boards/stores'; +import { mockTracking } from 'helpers/tracking_helper'; + +describe('ConfigToggle', () => { + let wrapper; + + Vue.use(Vuex); + + const createComponent = (provide = {}) => + shallowMount(ConfigToggle, { + store, + provide: { + canAdminList: true, + ...provide, + }, + }); + + const findButton = () => wrapper.findComponent(GlButton); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a button with label `View scope` when `canAdminList` is `false`', () => { + wrapper = createComponent({ canAdminList: false }); + expect(findButton().text()).toBe('View scope'); + }); + + it('renders a button with label `Edit board` when `canAdminList` is `true`', () => { + wrapper = createComponent(); + expect(findButton().text()).toBe('Edit board'); + }); + + it('emits `showBoardModal` when button is clicked', () => { + const eventHubSpy = jest.spyOn(eventHub, '$emit'); + wrapper = createComponent(); + + findButton().vm.$emit('click', { preventDefault: () => {} }); + + expect(eventHubSpy).toHaveBeenCalledWith('showBoardModal', 'edit'); + }); + + it('tracks clicking the button', () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + wrapper = createComponent(); + + findButton().vm.$emit('click', { preventDefault: () => {} }); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', { + label: 'edit_board', + }); + }); +}); diff --git a/spec/frontend/boards/components/toggle_focus_spec.js b/spec/frontend/boards/components/toggle_focus_spec.js new file mode 100644 index 00000000000..3cbaac91f8d --- /dev/null +++ b/spec/frontend/boards/components/toggle_focus_spec.js @@ -0,0 +1,47 @@ +import { GlButton } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import ToggleFocus from '~/boards/components/toggle_focus.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +describe('ToggleFocus', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(ToggleFocus, { + directives: { + GlTooltip: createMockDirective(), + }, + attachTo: document.body, + }); + }; + + const findButton = () => wrapper.findComponent(GlButton); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a button with `maximize` icon', () => { + createComponent(); + + expect(findButton().props('icon')).toBe('maximize'); + expect(findButton().attributes('aria-label')).toBe(ToggleFocus.i18n.toggleFocusMode); + }); + + it('contains a tooltip with title', () => { + createComponent(); + const tooltip = getBinding(findButton().element, 'gl-tooltip'); + + expect(tooltip).toBeDefined(); + expect(findButton().attributes('title')).toBe(ToggleFocus.i18n.toggleFocusMode); + }); + + it('toggles the icon when the button is clicked', async () => { + createComponent(); + findButton().vm.$emit('click'); + await nextTick(); + + expect(findButton().props('icon')).toBe('minimize'); + }); +}); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 6ec39be5d29..1ee05d81f37 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -790,3 +790,5 @@ export const epicBoardListQueryResponse = (totalWeight = 5) => ({ }, }, }); + +export const DEFAULT_COLOR = '#1068bf'; diff --git a/spec/frontend/boards/project_select_spec.js b/spec/frontend/boards/project_select_spec.js index bd79060c54f..c45cd545155 100644 --- a/spec/frontend/boards/project_select_spec.js +++ b/spec/frontend/boards/project_select_spec.js @@ -1,16 +1,16 @@ import { GlDropdown, GlDropdownItem, + GlFormInput, GlSearchBoxByType, GlLoadingIcon, - GlFormInput, } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; -import waitForPromises from 'helpers/wait_for_promises'; import ProjectSelect from '~/boards/components/project_select.vue'; import defaultState from '~/boards/stores/state'; +import waitForPromises from 'helpers/wait_for_promises'; import { mockList, mockActiveGroupProjects } from './mock_data'; @@ -21,7 +21,7 @@ describe('ProjectSelect component', () => { let store; const findLabel = () => wrapper.find("[data-testid='header-label']"); - const findGlDropdown = () => wrapper.find(GlDropdown); + const findGlDropdown = () => wrapper.findComponent(GlDropdown); const findGlDropdownLoadingIcon = () => findGlDropdown().find('button:first-child').find(GlLoadingIcon); const findGlSearchBoxByType = () => wrapper.find(GlSearchBoxByType); @@ -137,7 +137,7 @@ describe('ProjectSelect component', () => { await nextTick(); const searchInput = findGlDropdown().findComponent(GlFormInput).element; - expect(document.activeElement).toEqual(searchInput); + expect(document.activeElement).toBe(searchInput); }); }); diff --git a/spec/frontend/ci_lint/mock_data.js b/spec/frontend/ci_lint/mock_data.js index 28ea0f55bf8..660b2ad6e8b 100644 --- a/spec/frontend/ci_lint/mock_data.js +++ b/spec/frontend/ci_lint/mock_data.js @@ -1,5 +1,16 @@ import { mockJobs } from 'jest/pipeline_editor/mock_data'; +export const mockLintDataError = { + data: { + lintCI: { + errors: ['Error message'], + warnings: ['Warning message'], + valid: false, + jobs: mockJobs, + }, + }, +}; + export const mockLintDataValid = { data: { lintCI: { diff --git a/spec/frontend/ci_secure_files/components/secure_files_list_spec.js b/spec/frontend/ci_secure_files/components/secure_files_list_spec.js index ad5f8a56ced..04d38a3281a 100644 --- a/spec/frontend/ci_secure_files/components/secure_files_list_spec.js +++ b/spec/frontend/ci_secure_files/components/secure_files_list_spec.js @@ -1,10 +1,12 @@ -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlModal } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import { mount } from '@vue/test-utils'; import axios from '~/lib/utils/axios_utils'; import SecureFilesList from '~/ci_secure_files/components/secure_files_list.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import Api from '~/api'; import { secureFiles } from '../mock_data'; @@ -22,15 +24,18 @@ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${dummyProj describe('SecureFilesList', () => { let wrapper; let mock; + let trackingSpy; beforeEach(() => { originalGon = window.gon; + trackingSpy = mockTracking(undefined, undefined, jest.spyOn); window.gon = { ...dummyGon }; }); afterEach(() => { wrapper.destroy(); mock.restore(); + unmockTracking(); window.gon = originalGon; }); @@ -52,7 +57,9 @@ describe('SecureFilesList', () => { const findPagination = () => wrapper.findAll('ul.pagination'); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findUploadButton = () => wrapper.findAll('span.gl-button-text'); - const findDeleteButton = () => wrapper.findAll('tbody tr td button.btn-danger'); + const findDeleteModal = () => wrapper.findComponent(GlModal); + const findUploadInput = () => wrapper.findAll('input[type="file"]').at(0); + const findDeleteButton = () => wrapper.findAll('[data-testid="delete-button"]'); describe('when secure files exist in a project', () => { beforeEach(async () => { @@ -64,7 +71,7 @@ describe('SecureFilesList', () => { }); it('displays a table with expected headers', () => { - const headers = ['Filename', 'Uploaded']; + const headers = ['File name', 'Uploaded date']; headers.forEach((header, i) => { expect(findHeaderAt(i).text()).toBe(header); }); @@ -78,6 +85,30 @@ describe('SecureFilesList', () => { expect(findCell(0, 0).text()).toBe(secureFile.name); expect(findCell(0, 1).find(TimeAgoTooltip).props('time')).toBe(secureFile.created_at); }); + + describe('event tracking', () => { + it('sends tracking information on list load', () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'render_secure_files_list', {}); + }); + + it('sends tracking information on file upload', async () => { + Api.uploadProjectSecureFile = jest.fn().mockResolvedValue(); + Object.defineProperty(findUploadInput().element, 'files', { value: [{}] }); + findUploadInput().trigger('change'); + + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'upload_secure_file', {}); + }); + + it('sends tracking information on file deletion', async () => { + Api.deleteProjectSecureFile = jest.fn().mockResolvedValue(); + findDeleteModal().vm.$emit('ok'); + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'delete_secure_file', {}); + }); + }); }); describe('when no secure files exist in a project', () => { @@ -90,14 +121,14 @@ describe('SecureFilesList', () => { }); it('displays a table with expected headers', () => { - const headers = ['Filename', 'Uploaded']; + const headers = ['File name', 'Uploaded date']; headers.forEach((header, i) => { expect(findHeaderAt(i).text()).toBe(header); }); }); it('displays a table with a no records message', () => { - expect(findCell(0, 0).text()).toBe('There are no records to show'); + expect(findCell(0, 0).text()).toBe('There are no secure files yet.'); }); }); diff --git a/spec/frontend/clusters_list/components/agent_table_spec.js b/spec/frontend/clusters_list/components/agent_table_spec.js index 2a43b45a2f5..b78f0a3686c 100644 --- a/spec/frontend/clusters_list/components/agent_table_spec.js +++ b/spec/frontend/clusters_list/components/agent_table_spec.js @@ -70,10 +70,10 @@ describe('AgentTable', () => { }); it.each` - status | iconName | lineNumber - ${'Never connected'} | ${'status-neutral'} | ${0} - ${'Connected'} | ${'status-success'} | ${1} - ${'Not connected'} | ${'severity-critical'} | ${2} + status | iconName | lineNumber + ${'Never connected'} | ${'status-neutral'} | ${0} + ${'Connected'} | ${'status-success'} | ${1} + ${'Not connected'} | ${'status-alert'} | ${2} `( 'displays agent connection status as "$status" at line $lineNumber', ({ status, iconName, lineNumber }) => { diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js index c150a7f05d0..5c7635c1617 100644 --- a/spec/frontend/clusters_list/components/clusters_spec.js +++ b/spec/frontend/clusters_list/components/clusters_spec.js @@ -103,11 +103,9 @@ describe('Clusters', () => { }); describe('when is loaded as a child component', () => { - beforeEach(() => { + it("shouldn't render pagination buttons", () => { createWrapper({ limit: 6 }); - }); - it("shouldn't render pagination buttons", () => { expect(findPaginatedButtons().exists()).toBe(false); }); }); diff --git a/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js b/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js index 646d068e795..154035a46ed 100644 --- a/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js @@ -44,6 +44,12 @@ describe('content_editor/components/bubble_menus/code_block', () => { }); }; + const preTag = ({ language, content = 'test' } = {}) => { + const languageAttr = language ? ` lang="${language}"` : ''; + + return `<pre class="js-syntax-highlight"${languageAttr}>${content}</pre>`; + }; + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); const findDropdownItemsData = () => findDropdownItems().wrappers.map((x) => ({ @@ -62,7 +68,7 @@ describe('content_editor/components/bubble_menus/code_block', () => { }); it('renders bubble menu component', async () => { - tiptapEditor.commands.insertContent('<pre>test</pre>'); + tiptapEditor.commands.insertContent(preTag()); bubbleMenu = wrapper.findComponent(BubbleMenu); await emitEditorEvent({ event: 'transaction', tiptapEditor }); @@ -72,7 +78,7 @@ describe('content_editor/components/bubble_menus/code_block', () => { }); it('selects plaintext language by default', async () => { - tiptapEditor.commands.insertContent('<pre>test</pre>'); + tiptapEditor.commands.insertContent(preTag()); bubbleMenu = wrapper.findComponent(BubbleMenu); await emitEditorEvent({ event: 'transaction', tiptapEditor }); @@ -81,7 +87,7 @@ describe('content_editor/components/bubble_menus/code_block', () => { }); it('selects appropriate language based on the code block', async () => { - tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); + tiptapEditor.commands.insertContent(preTag({ language: 'javascript' })); bubbleMenu = wrapper.findComponent(BubbleMenu); await emitEditorEvent({ event: 'transaction', tiptapEditor }); @@ -90,7 +96,7 @@ describe('content_editor/components/bubble_menus/code_block', () => { }); it('selects diagram sytnax for mermaid', async () => { - tiptapEditor.commands.insertContent('<pre lang="mermaid">test</pre>'); + tiptapEditor.commands.insertContent(preTag({ language: 'mermaid' })); bubbleMenu = wrapper.findComponent(BubbleMenu); await emitEditorEvent({ event: 'transaction', tiptapEditor }); @@ -99,7 +105,7 @@ describe('content_editor/components/bubble_menus/code_block', () => { }); it("selects Custom (syntax) if the language doesn't exist in the list", async () => { - tiptapEditor.commands.insertContent('<pre lang="nomnoml">test</pre>'); + tiptapEditor.commands.insertContent(preTag({ language: 'nomnoml' })); bubbleMenu = wrapper.findComponent(BubbleMenu); await emitEditorEvent({ event: 'transaction', tiptapEditor }); @@ -109,19 +115,20 @@ describe('content_editor/components/bubble_menus/code_block', () => { describe('copy button', () => { it('copies the text of the code block', async () => { + const content = 'var a = Math.PI / 2;'; jest.spyOn(navigator.clipboard, 'writeText'); - tiptapEditor.commands.insertContent('<pre lang="javascript">var a = Math.PI / 2;</pre>'); + tiptapEditor.commands.insertContent(preTag({ language: 'javascript', content })); await wrapper.findByTestId('copy-code-block').vm.$emit('click'); - expect(navigator.clipboard.writeText).toHaveBeenCalledWith('var a = Math.PI / 2;'); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(content); }); }); describe('delete button', () => { it('deletes the code block', async () => { - tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); + tiptapEditor.commands.insertContent(preTag({ language: 'javascript' })); await wrapper.findByTestId('delete-code-block').vm.$emit('click'); @@ -164,7 +171,7 @@ describe('content_editor/components/bubble_menus/code_block', () => { describe('when opened and search is changed', () => { beforeEach(async () => { - tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); + tiptapEditor.commands.insertContent(preTag({ language: 'javascript' })); wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'js'); diff --git a/spec/frontend/content_editor/components/bubble_menus/formatting_spec.js b/spec/frontend/content_editor/components/bubble_menus/formatting_spec.js index 6479c0ba008..1e2f58d9e40 100644 --- a/spec/frontend/content_editor/components/bubble_menus/formatting_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/formatting_spec.js @@ -46,12 +46,14 @@ describe('content_editor/components/bubble_menus/formatting', () => { }); describe.each` - testId | controlProps - ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold', size: 'medium', category: 'tertiary' }} - ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic', size: 'medium', category: 'tertiary' }} - ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike', size: 'medium', category: 'tertiary' }} - ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode', size: 'medium', category: 'tertiary' }} - ${'link'} | ${{ contentType: 'link', iconName: 'link', label: 'Insert link', editorCommand: 'toggleLink', editorCommandParams: { href: '' }, size: 'medium', category: 'tertiary' }} + testId | controlProps + ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }} + ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }} + ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike' }} + ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }} + ${'superscript'} | ${{ contentType: 'superscript', iconName: 'superscript', label: 'Superscript', editorCommand: 'toggleSuperscript' }} + ${'subscript'} | ${{ contentType: 'subscript', iconName: 'subscript', label: 'Subscript', editorCommand: 'toggleSubscript' }} + ${'link'} | ${{ contentType: 'link', iconName: 'link', label: 'Insert link', editorCommand: 'toggleLink', editorCommandParams: { href: '' } }} `('given a $testId toolbar control', ({ testId, controlProps }) => { beforeEach(() => { buildWrapper(); @@ -60,9 +62,13 @@ describe('content_editor/components/bubble_menus/formatting', () => { it('renders the toolbar control with the provided properties', () => { expect(wrapper.findByTestId(testId).exists()).toBe(true); - Object.keys(controlProps).forEach((propName) => { - expect(wrapper.findByTestId(testId).props(propName)).toEqual(controlProps[propName]); - }); + expect(wrapper.findByTestId(testId).props()).toEqual( + expect.objectContaining({ + ...controlProps, + size: 'medium', + category: 'tertiary', + }), + ); }); it('tracks the execution of toolbar controls', () => { diff --git a/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js index 0334a18c9a1..351fd967719 100644 --- a/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js +++ b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js @@ -2,22 +2,26 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; import ToolbarMoreDropdown from '~/content_editor/components/toolbar_more_dropdown.vue'; import Diagram from '~/content_editor/extensions/diagram'; import HorizontalRule from '~/content_editor/extensions/horizontal_rule'; -import { createTestEditor, mockChainedCommands } from '../test_utils'; +import eventHubFactory from '~/helpers/event_hub_factory'; +import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils'; describe('content_editor/components/toolbar_more_dropdown', () => { let wrapper; let tiptapEditor; + let eventHub; const buildEditor = () => { tiptapEditor = createTestEditor({ extensions: [Diagram, HorizontalRule], }); + eventHub = eventHubFactory(); }; const buildWrapper = (propsData = {}) => { wrapper = mountExtended(ToolbarMoreDropdown, { provide: { tiptapEditor, + eventHub, }, propsData, }); @@ -33,19 +37,30 @@ describe('content_editor/components/toolbar_more_dropdown', () => { }); describe.each` - label | contentType | data - ${'Mermaid diagram'} | ${'diagram'} | ${{ language: 'mermaid' }} - ${'PlantUML diagram'} | ${'diagram'} | ${{ language: 'plantuml' }} - ${'Horizontal rule'} | ${'horizontalRule'} | ${undefined} - `('when option $label is clicked', ({ label, contentType, data }) => { - it(`inserts a ${contentType}`, async () => { - const commands = mockChainedCommands(tiptapEditor, ['setNode', 'focus', 'run']); + name | contentType | command | params + ${'Code block'} | ${'codeBlock'} | ${'setNode'} | ${['codeBlock']} + ${'Details block'} | ${'details'} | ${'toggleList'} | ${['details', 'detailsContent']} + ${'Bullet list'} | ${'bulletList'} | ${'toggleList'} | ${['bulletList', 'listItem']} + ${'Ordered list'} | ${'orderedList'} | ${'toggleList'} | ${['orderedList', 'listItem']} + ${'Task list'} | ${'taskList'} | ${'toggleList'} | ${['taskList', 'taskItem']} + ${'Mermaid diagram'} | ${'diagram'} | ${'setNode'} | ${['diagram', { language: 'mermaid' }]} + ${'PlantUML diagram'} | ${'diagram'} | ${'setNode'} | ${['diagram', { language: 'plantuml' }]} + ${'Horizontal rule'} | ${'horizontalRule'} | ${'setHorizontalRule'} | ${[]} + `('when option $label is clicked', ({ name, command, contentType, params }) => { + let commands; + let btn; + + beforeEach(async () => { + commands = mockChainedCommands(tiptapEditor, [command, 'focus', 'run']); + btn = wrapper.findByRole('menuitem', { name }); + }); - const btn = wrapper.findByRole('menuitem', { name: label }); + it(`inserts a ${contentType}`, async () => { await btn.trigger('click'); + await emitEditorEvent({ event: 'transaction', tiptapEditor }); expect(commands.focus).toHaveBeenCalled(); - expect(commands.setNode).toHaveBeenCalledWith(contentType, data); + expect(commands[command]).toHaveBeenCalledWith(...params); expect(commands.run).toHaveBeenCalled(); expect(wrapper.emitted('execute')).toEqual([[{ contentType }]]); diff --git a/spec/frontend/content_editor/components/top_toolbar_spec.js b/spec/frontend/content_editor/components/top_toolbar_spec.js index d98a9a52aff..2acb6e14ce0 100644 --- a/spec/frontend/content_editor/components/top_toolbar_spec.js +++ b/spec/frontend/content_editor/components/top_toolbar_spec.js @@ -24,17 +24,15 @@ describe('content_editor/components/top_toolbar', () => { describe.each` testId | controlProps + ${'text-styles'} | ${{}} ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }} ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }} - ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike' }} - ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }} ${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }} + ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }} + ${'link'} | ${{}} ${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }} ${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }} - ${'details'} | ${{ contentType: 'details', iconName: 'details-block', label: 'Add a collapsible section', editorCommand: 'toggleDetails' }} - ${'code-block'} | ${{ contentType: 'codeBlock', iconName: 'doc-code', label: 'Insert a code block', editorCommand: 'toggleCodeBlock' }} - ${'text-styles'} | ${{}} - ${'link'} | ${{}} + ${'task-list'} | ${{ contentType: 'taskList', iconName: 'list-task', label: 'Add a task list', editorCommand: 'toggleTaskList' }} ${'image'} | ${{}} ${'table'} | ${{}} ${'more'} | ${{}} diff --git a/spec/frontend/content_editor/extensions/html_nodes_spec.js b/spec/frontend/content_editor/extensions/html_nodes_spec.js new file mode 100644 index 00000000000..24c68239025 --- /dev/null +++ b/spec/frontend/content_editor/extensions/html_nodes_spec.js @@ -0,0 +1,42 @@ +import HTMLNodes from '~/content_editor/extensions/html_nodes'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/extensions/html_nodes', () => { + let tiptapEditor; + let doc; + let div; + let pre; + let p; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [...HTMLNodes] }); + + ({ + builders: { doc, p, pre, div }, + } = createDocBuilder({ + tiptapEditor, + names: { + ...HTMLNodes.reduce( + (builders, htmlNode) => ({ + ...builders, + [htmlNode.name]: { nodeType: htmlNode.name }, + }), + {}, + ), + }, + })); + }); + + it.each` + input | insertedNodes + ${'<div><p>foo</p></div>'} | ${() => div(p('foo'))} + ${'<pre><p>foo</p></pre>'} | ${() => pre(p('foo'))} + `('parses and creates nodes for $input', ({ input, insertedNodes }) => { + const expectedDoc = doc(insertedNodes()); + + tiptapEditor.commands.setContent(input); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + expect(tiptapEditor.getHTML()).toEqual(input); + }); +}); diff --git a/spec/frontend/content_editor/extensions/paste_markdown_spec.js b/spec/frontend/content_editor/extensions/paste_markdown_spec.js index 5d46c2c0650..53efda6aee2 100644 --- a/spec/frontend/content_editor/extensions/paste_markdown_spec.js +++ b/spec/frontend/content_editor/extensions/paste_markdown_spec.js @@ -14,7 +14,7 @@ import { import waitForPromises from 'helpers/wait_for_promises'; import { createTestEditor, createDocBuilder, waitUntilNextDocTransaction } from '../test_utils'; -const CODE_BLOCK_HTML = '<pre lang="javascript">var a = 2;</pre>'; +const CODE_BLOCK_HTML = '<pre class="js-syntax-highlight" lang="javascript">var a = 2;</pre>'; const DIAGRAM_HTML = '<img data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,WzxmcmFtZT5EZWNvcmF0b3IgcGF0dGVybl0=">'; const FRONTMATTER_HTML = '<pre lang="yaml" data-lang-params="frontmatter">key: value</pre>'; diff --git a/spec/frontend/content_editor/markdown_snapshot_spec.js b/spec/frontend/content_editor/markdown_snapshot_spec.js new file mode 100644 index 00000000000..63ca66172e6 --- /dev/null +++ b/spec/frontend/content_editor/markdown_snapshot_spec.js @@ -0,0 +1,10 @@ +import path from 'path'; +import { describeMarkdownSnapshots } from 'jest/content_editor/markdown_snapshot_spec_helper'; + +jest.mock('~/emoji'); + +const glfmSpecificationDir = path.join(__dirname, '..', '..', '..', 'glfm_specification'); + +// See https://docs.gitlab.com/ee/development/gitlab_flavored_markdown/specification_guide/#markdown-snapshot-testing +// for documentation on this spec. +describeMarkdownSnapshots('CE markdown snapshots in ContentEditor', glfmSpecificationDir); diff --git a/spec/frontend/content_editor/markdown_snapshot_spec_helper.js b/spec/frontend/content_editor/markdown_snapshot_spec_helper.js new file mode 100644 index 00000000000..05fa8e6a6b2 --- /dev/null +++ b/spec/frontend/content_editor/markdown_snapshot_spec_helper.js @@ -0,0 +1,102 @@ +// See https://docs.gitlab.com/ee/development/gitlab_flavored_markdown/specification_guide/#markdown-snapshot-testing +// for documentation on this spec. + +import fs from 'fs'; +import path from 'path'; +import jsYaml from 'js-yaml'; +import { pick } from 'lodash'; +import { + IMPLEMENTATION_ERROR_MSG, + renderHtmlAndJsonForAllExamples, +} from './render_html_and_json_for_all_examples'; + +const filterExamples = (examples) => { + const focusedMarkdownExamples = process.env.FOCUSED_MARKDOWN_EXAMPLES?.split(',') || []; + if (!focusedMarkdownExamples.length) { + return examples; + } + return pick(examples, focusedMarkdownExamples); +}; + +const loadExamples = (dir, fileName) => { + const yaml = fs.readFileSync(path.join(dir, fileName)); + const examples = jsYaml.safeLoad(yaml, {}); + return filterExamples(examples); +}; + +// eslint-disable-next-line jest/no-export +export const describeMarkdownSnapshots = (description, glfmSpecificationDir) => { + let actualHtmlAndJsonExamples; + let skipRunningSnapshotWysiwygHtmlTests; + let skipRunningSnapshotProsemirrorJsonTests; + + const exampleStatuses = loadExamples( + path.join(glfmSpecificationDir, 'input', 'gitlab_flavored_markdown'), + 'glfm_example_status.yml', + ); + const glfmExampleSnapshotsDir = path.join(glfmSpecificationDir, 'example_snapshots'); + const markdownExamples = loadExamples(glfmExampleSnapshotsDir, 'markdown.yml'); + const expectedHtmlExamples = loadExamples(glfmExampleSnapshotsDir, 'html.yml'); + const expectedProseMirrorJsonExamples = loadExamples( + glfmExampleSnapshotsDir, + 'prosemirror_json.yml', + ); + + beforeAll(async () => { + return renderHtmlAndJsonForAllExamples(markdownExamples).then((examples) => { + actualHtmlAndJsonExamples = examples; + }); + }); + + describe(description, () => { + const exampleNames = Object.keys(markdownExamples); + + describe.each(exampleNames)('%s', (name) => { + const exampleNamePrefix = 'verifies conversion of GLFM to'; + skipRunningSnapshotWysiwygHtmlTests = + exampleStatuses[name]?.skip_running_snapshot_wysiwyg_html_tests; + skipRunningSnapshotProsemirrorJsonTests = + exampleStatuses[name]?.skip_running_snapshot_prosemirror_json_tests; + + const markdown = markdownExamples[name]; + + if (skipRunningSnapshotWysiwygHtmlTests) { + it.todo(`${exampleNamePrefix} HTML: ${skipRunningSnapshotWysiwygHtmlTests}`); + } else { + it(`${exampleNamePrefix} HTML`, async () => { + const expectedHtml = expectedHtmlExamples[name].wysiwyg; + const { html: actualHtml } = actualHtmlAndJsonExamples[name]; + + // noinspection JSUnresolvedFunction (required to avoid RubyMine type inspection warning, because custom matchers auto-imported via Jest test setup are not automatically resolved - see https://youtrack.jetbrains.com/issue/WEB-42350/matcher-for-jest-is-not-recognized-but-it-is-runable) + expect(actualHtml).toMatchExpectedForMarkdown( + 'HTML', + name, + markdown, + IMPLEMENTATION_ERROR_MSG, + expectedHtml, + ); + }); + } + + if (skipRunningSnapshotProsemirrorJsonTests) { + it.todo( + `${exampleNamePrefix} ProseMirror JSON: ${skipRunningSnapshotProsemirrorJsonTests}`, + ); + } else { + it(`${exampleNamePrefix} ProseMirror JSON`, async () => { + const expectedJson = expectedProseMirrorJsonExamples[name]; + const { json: actualJson } = actualHtmlAndJsonExamples[name]; + + // noinspection JSUnresolvedFunction + expect(actualJson).toMatchExpectedForMarkdown( + 'JSON', + name, + markdown, + IMPLEMENTATION_ERROR_MSG, + expectedJson, + ); + }); + } + }); + }); +}; diff --git a/spec/frontend/content_editor/remark_markdown_processing_spec.js b/spec/frontend/content_editor/remark_markdown_processing_spec.js index 60dc540e192..48adceaab58 100644 --- a/spec/frontend/content_editor/remark_markdown_processing_spec.js +++ b/spec/frontend/content_editor/remark_markdown_processing_spec.js @@ -6,6 +6,7 @@ import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight import FootnoteDefinition from '~/content_editor/extensions/footnote_definition'; import FootnoteReference from '~/content_editor/extensions/footnote_reference'; import HardBreak from '~/content_editor/extensions/hard_break'; +import HTMLNodes from '~/content_editor/extensions/html_nodes'; import Heading from '~/content_editor/extensions/heading'; import HorizontalRule from '~/content_editor/extensions/horizontal_rule'; import Image from '~/content_editor/extensions/image'; @@ -52,6 +53,7 @@ const tiptapEditor = createTestEditor({ TableCell, TaskList, TaskItem, + ...HTMLNodes, ], }); @@ -64,6 +66,7 @@ const { bulletList, code, codeBlock, + div, footnoteDefinition, footnoteReference, hardBreak, @@ -74,6 +77,7 @@ const { link, listItem, orderedList, + pre, strike, table, tableRow, @@ -108,14 +112,21 @@ const { tableRow: { nodeType: TableRow.name }, taskItem: { nodeType: TaskItem.name }, taskList: { nodeType: TaskList.name }, + ...HTMLNodes.reduce( + (builders, htmlNode) => ({ + ...builders, + [htmlNode.name]: { nodeType: htmlNode.name }, + }), + {}, + ), }, }); describe('Client side Markdown processing', () => { - const deserialize = async (content) => { + const deserialize = async (markdown) => { const { document } = await remarkMarkdownDeserializer().deserialize({ schema: tiptapEditor.schema, - content, + markdown, }); return document; @@ -127,8 +138,8 @@ describe('Client side Markdown processing', () => { pristineDoc: document, }); - const sourceAttrs = (sourceMapKey, sourceMarkdown) => ({ - sourceMapKey, + const source = (sourceMarkdown) => ({ + sourceMapKey: expect.any(String), sourceMarkdown, }); @@ -136,63 +147,48 @@ describe('Client side Markdown processing', () => { { markdown: '__bold text__', expectedDoc: doc( - paragraph( - sourceAttrs('0:13', '__bold text__'), - bold(sourceAttrs('0:13', '__bold text__'), 'bold text'), - ), + paragraph(source('__bold text__'), bold(source('__bold text__'), 'bold text')), ), }, { markdown: '**bold text**', expectedDoc: doc( - paragraph( - sourceAttrs('0:13', '**bold text**'), - bold(sourceAttrs('0:13', '**bold text**'), 'bold text'), - ), + paragraph(source('**bold text**'), bold(source('**bold text**'), 'bold text')), ), }, { markdown: '<strong>bold text</strong>', expectedDoc: doc( paragraph( - sourceAttrs('0:26', '<strong>bold text</strong>'), - bold(sourceAttrs('0:26', '<strong>bold text</strong>'), 'bold text'), + source('<strong>bold text</strong>'), + bold(source('<strong>bold text</strong>'), 'bold text'), ), ), }, { markdown: '<b>bold text</b>', expectedDoc: doc( - paragraph( - sourceAttrs('0:16', '<b>bold text</b>'), - bold(sourceAttrs('0:16', '<b>bold text</b>'), 'bold text'), - ), + paragraph(source('<b>bold text</b>'), bold(source('<b>bold text</b>'), 'bold text')), ), }, { markdown: '_italic text_', expectedDoc: doc( - paragraph( - sourceAttrs('0:13', '_italic text_'), - italic(sourceAttrs('0:13', '_italic text_'), 'italic text'), - ), + paragraph(source('_italic text_'), italic(source('_italic text_'), 'italic text')), ), }, { markdown: '*italic text*', expectedDoc: doc( - paragraph( - sourceAttrs('0:13', '*italic text*'), - italic(sourceAttrs('0:13', '*italic text*'), 'italic text'), - ), + paragraph(source('*italic text*'), italic(source('*italic text*'), 'italic text')), ), }, { markdown: '<em>italic text</em>', expectedDoc: doc( paragraph( - sourceAttrs('0:20', '<em>italic text</em>'), - italic(sourceAttrs('0:20', '<em>italic text</em>'), 'italic text'), + source('<em>italic text</em>'), + italic(source('<em>italic text</em>'), 'italic text'), ), ), }, @@ -200,28 +196,25 @@ describe('Client side Markdown processing', () => { markdown: '<i>italic text</i>', expectedDoc: doc( paragraph( - sourceAttrs('0:18', '<i>italic text</i>'), - italic(sourceAttrs('0:18', '<i>italic text</i>'), 'italic text'), + source('<i>italic text</i>'), + italic(source('<i>italic text</i>'), 'italic text'), ), ), }, { markdown: '`inline code`', expectedDoc: doc( - paragraph( - sourceAttrs('0:13', '`inline code`'), - code(sourceAttrs('0:13', '`inline code`'), 'inline code'), - ), + paragraph(source('`inline code`'), code(source('`inline code`'), 'inline code')), ), }, { markdown: '**`inline code bold`**', expectedDoc: doc( paragraph( - sourceAttrs('0:22', '**`inline code bold`**'), + source('**`inline code bold`**'), bold( - sourceAttrs('0:22', '**`inline code bold`**'), - code(sourceAttrs('2:20', '`inline code bold`'), 'inline code bold'), + source('**`inline code bold`**'), + code(source('`inline code bold`'), 'inline code bold'), ), ), ), @@ -230,10 +223,10 @@ describe('Client side Markdown processing', () => { markdown: '_`inline code italics`_', expectedDoc: doc( paragraph( - sourceAttrs('0:23', '_`inline code italics`_'), + source('_`inline code italics`_'), italic( - sourceAttrs('0:23', '_`inline code italics`_'), - code(sourceAttrs('1:22', '`inline code italics`'), 'inline code italics'), + source('_`inline code italics`_'), + code(source('`inline code italics`'), 'inline code italics'), ), ), ), @@ -246,8 +239,8 @@ describe('Client side Markdown processing', () => { `, expectedDoc: doc( paragraph( - sourceAttrs('0:28', '<i class="foo">\n *bar*\n</i>'), - italic(sourceAttrs('0:28', '<i class="foo">\n *bar*\n</i>'), '\n *bar*\n'), + source('<i class="foo">\n *bar*\n</i>'), + italic(source('<i class="foo">\n *bar*\n</i>'), '\n *bar*\n'), ), ), }, @@ -259,8 +252,8 @@ describe('Client side Markdown processing', () => { `, expectedDoc: doc( paragraph( - sourceAttrs('0:27', '<img src="bar" alt="foo" />'), - image({ ...sourceAttrs('0:27', '<img src="bar" alt="foo" />'), alt: 'foo', src: 'bar' }), + source('<img src="bar" alt="foo" />'), + image({ ...source('<img src="bar" alt="foo" />'), alt: 'foo', src: 'bar' }), ), ), }, @@ -273,15 +266,12 @@ describe('Client side Markdown processing', () => { `, expectedDoc: doc( bulletList( - sourceAttrs('0:13', '- List item 1'), - listItem( - sourceAttrs('0:13', '- List item 1'), - paragraph(sourceAttrs('2:13', 'List item 1'), 'List item 1'), - ), + source('- List item 1'), + listItem(source('- List item 1'), paragraph(source('List item 1'), 'List item 1')), ), paragraph( - sourceAttrs('15:42', '<img src="bar" alt="foo" />'), - image({ ...sourceAttrs('15:42', '<img src="bar" alt="foo" />'), alt: 'foo', src: 'bar' }), + source('<img src="bar" alt="foo" />'), + image({ ...source('<img src="bar" alt="foo" />'), alt: 'foo', src: 'bar' }), ), ), }, @@ -289,10 +279,10 @@ describe('Client side Markdown processing', () => { markdown: '[GitLab](https://gitlab.com "Go to GitLab")', expectedDoc: doc( paragraph( - sourceAttrs('0:43', '[GitLab](https://gitlab.com "Go to GitLab")'), + source('[GitLab](https://gitlab.com "Go to GitLab")'), link( { - ...sourceAttrs('0:43', '[GitLab](https://gitlab.com "Go to GitLab")'), + ...source('[GitLab](https://gitlab.com "Go to GitLab")'), href: 'https://gitlab.com', title: 'Go to GitLab', }, @@ -305,12 +295,12 @@ describe('Client side Markdown processing', () => { markdown: '**[GitLab](https://gitlab.com "Go to GitLab")**', expectedDoc: doc( paragraph( - sourceAttrs('0:47', '**[GitLab](https://gitlab.com "Go to GitLab")**'), + source('**[GitLab](https://gitlab.com "Go to GitLab")**'), bold( - sourceAttrs('0:47', '**[GitLab](https://gitlab.com "Go to GitLab")**'), + source('**[GitLab](https://gitlab.com "Go to GitLab")**'), link( { - ...sourceAttrs('2:45', '[GitLab](https://gitlab.com "Go to GitLab")'), + ...source('[GitLab](https://gitlab.com "Go to GitLab")'), href: 'https://gitlab.com', title: 'Go to GitLab', }, @@ -324,10 +314,10 @@ describe('Client side Markdown processing', () => { markdown: 'www.commonmark.org', expectedDoc: doc( paragraph( - sourceAttrs('0:18', 'www.commonmark.org'), + source('www.commonmark.org'), link( { - ...sourceAttrs('0:18', 'www.commonmark.org'), + ...source('www.commonmark.org'), href: 'http://www.commonmark.org', }, 'www.commonmark.org', @@ -339,11 +329,11 @@ describe('Client side Markdown processing', () => { markdown: 'Visit www.commonmark.org/help for more information.', expectedDoc: doc( paragraph( - sourceAttrs('0:51', 'Visit www.commonmark.org/help for more information.'), + source('Visit www.commonmark.org/help for more information.'), 'Visit ', link( { - ...sourceAttrs('6:29', 'www.commonmark.org/help'), + ...source('www.commonmark.org/help'), href: 'http://www.commonmark.org/help', }, 'www.commonmark.org/help', @@ -356,11 +346,11 @@ describe('Client side Markdown processing', () => { markdown: 'hello@mail+xyz.example isn’t valid, but hello+xyz@mail.example is.', expectedDoc: doc( paragraph( - sourceAttrs('0:66', 'hello@mail+xyz.example isn’t valid, but hello+xyz@mail.example is.'), + source('hello@mail+xyz.example isn’t valid, but hello+xyz@mail.example is.'), 'hello@mail+xyz.example isn’t valid, but ', link( { - ...sourceAttrs('40:62', 'hello+xyz@mail.example'), + ...source('hello+xyz@mail.example'), href: 'mailto:hello+xyz@mail.example', }, 'hello+xyz@mail.example', @@ -373,11 +363,12 @@ describe('Client side Markdown processing', () => { markdown: '[https://gitlab.com>', expectedDoc: doc( paragraph( - sourceAttrs('0:20', '[https://gitlab.com>'), + source('[https://gitlab.com>'), '[', link( { - ...sourceAttrs(), + sourceMapKey: null, + sourceMarkdown: null, href: 'https://gitlab.com', }, 'https://gitlab.com', @@ -392,9 +383,9 @@ This is a paragraph with a\\ hard line break`, expectedDoc: doc( paragraph( - sourceAttrs('0:43', 'This is a paragraph with a\\\nhard line break'), + source('This is a paragraph with a\\\nhard line break'), 'This is a paragraph with a', - hardBreak(sourceAttrs('26:28', '\\\n')), + hardBreak(source('\\\n')), '\nhard line break', ), ), @@ -403,9 +394,9 @@ hard line break`, markdown: '![GitLab Logo](https://gitlab.com/logo.png "GitLab Logo")', expectedDoc: doc( paragraph( - sourceAttrs('0:57', '![GitLab Logo](https://gitlab.com/logo.png "GitLab Logo")'), + source('![GitLab Logo](https://gitlab.com/logo.png "GitLab Logo")'), image({ - ...sourceAttrs('0:57', '![GitLab Logo](https://gitlab.com/logo.png "GitLab Logo")'), + ...source('![GitLab Logo](https://gitlab.com/logo.png "GitLab Logo")'), alt: 'GitLab Logo', src: 'https://gitlab.com/logo.png', title: 'GitLab Logo', @@ -415,49 +406,43 @@ hard line break`, }, { markdown: '---', - expectedDoc: doc(horizontalRule(sourceAttrs('0:3', '---'))), + expectedDoc: doc(horizontalRule(source('---'))), }, { markdown: '***', - expectedDoc: doc(horizontalRule(sourceAttrs('0:3', '***'))), + expectedDoc: doc(horizontalRule(source('***'))), }, { markdown: '___', - expectedDoc: doc(horizontalRule(sourceAttrs('0:3', '___'))), + expectedDoc: doc(horizontalRule(source('___'))), }, { markdown: '<hr>', - expectedDoc: doc(horizontalRule(sourceAttrs('0:4', '<hr>'))), + expectedDoc: doc(horizontalRule(source('<hr>'))), }, { markdown: '# Heading 1', - expectedDoc: doc(heading({ ...sourceAttrs('0:11', '# Heading 1'), level: 1 }, 'Heading 1')), + expectedDoc: doc(heading({ ...source('# Heading 1'), level: 1 }, 'Heading 1')), }, { markdown: '## Heading 2', - expectedDoc: doc(heading({ ...sourceAttrs('0:12', '## Heading 2'), level: 2 }, 'Heading 2')), + expectedDoc: doc(heading({ ...source('## Heading 2'), level: 2 }, 'Heading 2')), }, { markdown: '### Heading 3', - expectedDoc: doc(heading({ ...sourceAttrs('0:13', '### Heading 3'), level: 3 }, 'Heading 3')), + expectedDoc: doc(heading({ ...source('### Heading 3'), level: 3 }, 'Heading 3')), }, { markdown: '#### Heading 4', - expectedDoc: doc( - heading({ ...sourceAttrs('0:14', '#### Heading 4'), level: 4 }, 'Heading 4'), - ), + expectedDoc: doc(heading({ ...source('#### Heading 4'), level: 4 }, 'Heading 4')), }, { markdown: '##### Heading 5', - expectedDoc: doc( - heading({ ...sourceAttrs('0:15', '##### Heading 5'), level: 5 }, 'Heading 5'), - ), + expectedDoc: doc(heading({ ...source('##### Heading 5'), level: 5 }, 'Heading 5')), }, { markdown: '###### Heading 6', - expectedDoc: doc( - heading({ ...sourceAttrs('0:16', '###### Heading 6'), level: 6 }, 'Heading 6'), - ), + expectedDoc: doc(heading({ ...source('###### Heading 6'), level: 6 }, 'Heading 6')), }, { markdown: ` @@ -465,9 +450,7 @@ Heading one ====== `, - expectedDoc: doc( - heading({ ...sourceAttrs('0:18', 'Heading\none\n======'), level: 1 }, 'Heading\none'), - ), + expectedDoc: doc(heading({ ...source('Heading\none\n======'), level: 1 }, 'Heading\none')), }, { markdown: ` @@ -475,9 +458,7 @@ Heading two ------- `, - expectedDoc: doc( - heading({ ...sourceAttrs('0:19', 'Heading\ntwo\n-------'), level: 2 }, 'Heading\ntwo'), - ), + expectedDoc: doc(heading({ ...source('Heading\ntwo\n-------'), level: 2 }, 'Heading\ntwo')), }, { markdown: ` @@ -486,15 +467,9 @@ two `, expectedDoc: doc( bulletList( - sourceAttrs('0:27', '- List item 1\n- List item 2'), - listItem( - sourceAttrs('0:13', '- List item 1'), - paragraph(sourceAttrs('2:13', 'List item 1'), 'List item 1'), - ), - listItem( - sourceAttrs('14:27', '- List item 2'), - paragraph(sourceAttrs('16:27', 'List item 2'), 'List item 2'), - ), + source('- List item 1\n- List item 2'), + listItem(source('- List item 1'), paragraph(source('List item 1'), 'List item 1')), + listItem(source('- List item 2'), paragraph(source('List item 2'), 'List item 2')), ), ), }, @@ -505,15 +480,9 @@ two `, expectedDoc: doc( bulletList( - sourceAttrs('0:27', '* List item 1\n* List item 2'), - listItem( - sourceAttrs('0:13', '* List item 1'), - paragraph(sourceAttrs('2:13', 'List item 1'), 'List item 1'), - ), - listItem( - sourceAttrs('14:27', '* List item 2'), - paragraph(sourceAttrs('16:27', 'List item 2'), 'List item 2'), - ), + source('* List item 1\n* List item 2'), + listItem(source('* List item 1'), paragraph(source('List item 1'), 'List item 1')), + listItem(source('* List item 2'), paragraph(source('List item 2'), 'List item 2')), ), ), }, @@ -524,15 +493,9 @@ two `, expectedDoc: doc( bulletList( - sourceAttrs('0:27', '+ List item 1\n+ List item 2'), - listItem( - sourceAttrs('0:13', '+ List item 1'), - paragraph(sourceAttrs('2:13', 'List item 1'), 'List item 1'), - ), - listItem( - sourceAttrs('14:27', '+ List item 2'), - paragraph(sourceAttrs('16:27', 'List item 2'), 'List item 2'), - ), + source('+ List item 1\n+ List item 2'), + listItem(source('+ List item 1'), paragraph(source('List item 1'), 'List item 1')), + listItem(source('+ List item 2'), paragraph(source('List item 2'), 'List item 2')), ), ), }, @@ -543,15 +506,9 @@ two `, expectedDoc: doc( orderedList( - sourceAttrs('0:29', '1. List item 1\n1. List item 2'), - listItem( - sourceAttrs('0:14', '1. List item 1'), - paragraph(sourceAttrs('3:14', 'List item 1'), 'List item 1'), - ), - listItem( - sourceAttrs('15:29', '1. List item 2'), - paragraph(sourceAttrs('18:29', 'List item 2'), 'List item 2'), - ), + source('1. List item 1\n1. List item 2'), + listItem(source('1. List item 1'), paragraph(source('List item 1'), 'List item 1')), + listItem(source('1. List item 2'), paragraph(source('List item 2'), 'List item 2')), ), ), }, @@ -562,15 +519,9 @@ two `, expectedDoc: doc( orderedList( - sourceAttrs('0:29', '1. List item 1\n2. List item 2'), - listItem( - sourceAttrs('0:14', '1. List item 1'), - paragraph(sourceAttrs('3:14', 'List item 1'), 'List item 1'), - ), - listItem( - sourceAttrs('15:29', '2. List item 2'), - paragraph(sourceAttrs('18:29', 'List item 2'), 'List item 2'), - ), + source('1. List item 1\n2. List item 2'), + listItem(source('1. List item 1'), paragraph(source('List item 1'), 'List item 1')), + listItem(source('2. List item 2'), paragraph(source('List item 2'), 'List item 2')), ), ), }, @@ -581,15 +532,9 @@ two `, expectedDoc: doc( orderedList( - sourceAttrs('0:29', '1) List item 1\n2) List item 2'), - listItem( - sourceAttrs('0:14', '1) List item 1'), - paragraph(sourceAttrs('3:14', 'List item 1'), 'List item 1'), - ), - listItem( - sourceAttrs('15:29', '2) List item 2'), - paragraph(sourceAttrs('18:29', 'List item 2'), 'List item 2'), - ), + source('1) List item 1\n2) List item 2'), + listItem(source('1) List item 1'), paragraph(source('List item 1'), 'List item 1')), + listItem(source('2) List item 2'), paragraph(source('List item 2'), 'List item 2')), ), ), }, @@ -600,15 +545,15 @@ two `, expectedDoc: doc( bulletList( - sourceAttrs('0:33', '- List item 1\n - Sub list item 1'), + source('- List item 1\n - Sub list item 1'), listItem( - sourceAttrs('0:33', '- List item 1\n - Sub list item 1'), - paragraph(sourceAttrs('2:13', 'List item 1'), 'List item 1'), + source('- List item 1\n - Sub list item 1'), + paragraph(source('List item 1'), 'List item 1'), bulletList( - sourceAttrs('16:33', '- Sub list item 1'), + source('- Sub list item 1'), listItem( - sourceAttrs('16:33', '- Sub list item 1'), - paragraph(sourceAttrs('18:33', 'Sub list item 1'), 'Sub list item 1'), + source('- Sub list item 1'), + paragraph(source('Sub list item 1'), 'Sub list item 1'), ), ), ), @@ -624,19 +569,13 @@ two `, expectedDoc: doc( bulletList( - sourceAttrs( - '0:66', - '- List item 1 paragraph 1\n\n List item 1 paragraph 2\n- List item 2', - ), - listItem( - sourceAttrs('0:52', '- List item 1 paragraph 1\n\n List item 1 paragraph 2'), - paragraph(sourceAttrs('2:25', 'List item 1 paragraph 1'), 'List item 1 paragraph 1'), - paragraph(sourceAttrs('29:52', 'List item 1 paragraph 2'), 'List item 1 paragraph 2'), - ), + source('- List item 1 paragraph 1\n\n List item 1 paragraph 2\n- List item 2'), listItem( - sourceAttrs('53:66', '- List item 2'), - paragraph(sourceAttrs('55:66', 'List item 2'), 'List item 2'), + source('- List item 1 paragraph 1\n\n List item 1 paragraph 2'), + paragraph(source('List item 1 paragraph 1'), 'List item 1 paragraph 1'), + paragraph(source('List item 1 paragraph 2'), 'List item 1 paragraph 2'), ), + listItem(source('- List item 2'), paragraph(source('List item 2'), 'List item 2')), ), ), }, @@ -646,13 +585,13 @@ two `, expectedDoc: doc( bulletList( - sourceAttrs('0:41', '- List item with an image ![bar](foo.png)'), + source('- List item with an image ![bar](foo.png)'), listItem( - sourceAttrs('0:41', '- List item with an image ![bar](foo.png)'), + source('- List item with an image ![bar](foo.png)'), paragraph( - sourceAttrs('2:41', 'List item with an image ![bar](foo.png)'), + source('List item with an image ![bar](foo.png)'), 'List item with an image', - image({ ...sourceAttrs('26:41', '![bar](foo.png)'), alt: 'bar', src: 'foo.png' }), + image({ ...source('![bar](foo.png)'), alt: 'bar', src: 'foo.png' }), ), ), ), @@ -664,8 +603,8 @@ two `, expectedDoc: doc( blockquote( - sourceAttrs('0:22', '> This is a blockquote'), - paragraph(sourceAttrs('2:22', 'This is a blockquote'), 'This is a blockquote'), + source('> This is a blockquote'), + paragraph(source('This is a blockquote'), 'This is a blockquote'), ), ), }, @@ -676,17 +615,11 @@ two `, expectedDoc: doc( blockquote( - sourceAttrs('0:31', '> - List item 1\n> - List item 2'), + source('> - List item 1\n> - List item 2'), bulletList( - sourceAttrs('2:31', '- List item 1\n> - List item 2'), - listItem( - sourceAttrs('2:15', '- List item 1'), - paragraph(sourceAttrs('4:15', 'List item 1'), 'List item 1'), - ), - listItem( - sourceAttrs('18:31', '- List item 2'), - paragraph(sourceAttrs('20:31', 'List item 2'), 'List item 2'), - ), + source('- List item 1\n> - List item 2'), + listItem(source('- List item 1'), paragraph(source('List item 1'), 'List item 1')), + listItem(source('- List item 2'), paragraph(source('List item 2'), 'List item 2')), ), ), ), @@ -699,10 +632,10 @@ code block `, expectedDoc: doc( - paragraph(sourceAttrs('0:10', 'code block'), 'code block'), + paragraph(source('code block'), 'code block'), codeBlock( { - ...sourceAttrs('12:42', " const fn = () => 'GitLab';"), + ...source(" const fn = () => 'GitLab';"), class: 'code highlight', language: null, }, @@ -719,7 +652,7 @@ const fn = () => 'GitLab'; expectedDoc: doc( codeBlock( { - ...sourceAttrs('0:44', "```javascript\nconst fn = () => 'GitLab';\n```"), + ...source("```javascript\nconst fn = () => 'GitLab';\n```"), class: 'code highlight', language: 'javascript', }, @@ -736,7 +669,7 @@ const fn = () => 'GitLab'; expectedDoc: doc( codeBlock( { - ...sourceAttrs('0:44', "~~~javascript\nconst fn = () => 'GitLab';\n~~~"), + ...source("~~~javascript\nconst fn = () => 'GitLab';\n~~~"), class: 'code highlight', language: 'javascript', }, @@ -752,7 +685,7 @@ const fn = () => 'GitLab'; expectedDoc: doc( codeBlock( { - ...sourceAttrs('0:7', '```\n```'), + ...source('```\n```'), class: 'code highlight', language: null, }, @@ -770,7 +703,7 @@ const fn = () => 'GitLab'; expectedDoc: doc( codeBlock( { - ...sourceAttrs('0:45', "```javascript\nconst fn = () => 'GitLab';\n\n```"), + ...source("```javascript\nconst fn = () => 'GitLab';\n\n```"), class: 'code highlight', language: 'javascript', }, @@ -782,8 +715,8 @@ const fn = () => 'GitLab'; markdown: '~~Strikedthrough text~~', expectedDoc: doc( paragraph( - sourceAttrs('0:23', '~~Strikedthrough text~~'), - strike(sourceAttrs('0:23', '~~Strikedthrough text~~'), 'Strikedthrough text'), + source('~~Strikedthrough text~~'), + strike(source('~~Strikedthrough text~~'), 'Strikedthrough text'), ), ), }, @@ -791,8 +724,8 @@ const fn = () => 'GitLab'; markdown: '<del>Strikedthrough text</del>', expectedDoc: doc( paragraph( - sourceAttrs('0:30', '<del>Strikedthrough text</del>'), - strike(sourceAttrs('0:30', '<del>Strikedthrough text</del>'), 'Strikedthrough text'), + source('<del>Strikedthrough text</del>'), + strike(source('<del>Strikedthrough text</del>'), 'Strikedthrough text'), ), ), }, @@ -800,11 +733,8 @@ const fn = () => 'GitLab'; markdown: '<strike>Strikedthrough text</strike>', expectedDoc: doc( paragraph( - sourceAttrs('0:36', '<strike>Strikedthrough text</strike>'), - strike( - sourceAttrs('0:36', '<strike>Strikedthrough text</strike>'), - 'Strikedthrough text', - ), + source('<strike>Strikedthrough text</strike>'), + strike(source('<strike>Strikedthrough text</strike>'), 'Strikedthrough text'), ), ), }, @@ -812,8 +742,8 @@ const fn = () => 'GitLab'; markdown: '<s>Strikedthrough text</s>', expectedDoc: doc( paragraph( - sourceAttrs('0:26', '<s>Strikedthrough text</s>'), - strike(sourceAttrs('0:26', '<s>Strikedthrough text</s>'), 'Strikedthrough text'), + source('<s>Strikedthrough text</s>'), + strike(source('<s>Strikedthrough text</s>'), 'Strikedthrough text'), ), ), }, @@ -826,21 +756,21 @@ const fn = () => 'GitLab'; taskList( { numeric: false, - ...sourceAttrs('0:45', '- [ ] task list item 1\n- [ ] task list item 2'), + ...source('- [ ] task list item 1\n- [ ] task list item 2'), }, taskItem( { checked: false, - ...sourceAttrs('0:22', '- [ ] task list item 1'), + ...source('- [ ] task list item 1'), }, - paragraph(sourceAttrs('6:22', 'task list item 1'), 'task list item 1'), + paragraph(source('task list item 1'), 'task list item 1'), ), taskItem( { checked: false, - ...sourceAttrs('23:45', '- [ ] task list item 2'), + ...source('- [ ] task list item 2'), }, - paragraph(sourceAttrs('29:45', 'task list item 2'), 'task list item 2'), + paragraph(source('task list item 2'), 'task list item 2'), ), ), ), @@ -854,21 +784,21 @@ const fn = () => 'GitLab'; taskList( { numeric: false, - ...sourceAttrs('0:45', '- [x] task list item 1\n- [x] task list item 2'), + ...source('- [x] task list item 1\n- [x] task list item 2'), }, taskItem( { checked: true, - ...sourceAttrs('0:22', '- [x] task list item 1'), + ...source('- [x] task list item 1'), }, - paragraph(sourceAttrs('6:22', 'task list item 1'), 'task list item 1'), + paragraph(source('task list item 1'), 'task list item 1'), ), taskItem( { checked: true, - ...sourceAttrs('23:45', '- [x] task list item 2'), + ...source('- [x] task list item 2'), }, - paragraph(sourceAttrs('29:45', 'task list item 2'), 'task list item 2'), + paragraph(source('task list item 2'), 'task list item 2'), ), ), ), @@ -882,21 +812,21 @@ const fn = () => 'GitLab'; taskList( { numeric: true, - ...sourceAttrs('0:47', '1. [ ] task list item 1\n2. [ ] task list item 2'), + ...source('1. [ ] task list item 1\n2. [ ] task list item 2'), }, taskItem( { checked: false, - ...sourceAttrs('0:23', '1. [ ] task list item 1'), + ...source('1. [ ] task list item 1'), }, - paragraph(sourceAttrs('7:23', 'task list item 1'), 'task list item 1'), + paragraph(source('task list item 1'), 'task list item 1'), ), taskItem( { checked: false, - ...sourceAttrs('24:47', '2. [ ] task list item 2'), + ...source('2. [ ] task list item 2'), }, - paragraph(sourceAttrs('31:47', 'task list item 2'), 'task list item 2'), + paragraph(source('task list item 2'), 'task list item 2'), ), ), ), @@ -909,16 +839,16 @@ const fn = () => 'GitLab'; `, expectedDoc: doc( table( - sourceAttrs('0:29', '| a | b |\n|---|---|\n| c | d |'), + source('| a | b |\n|---|---|\n| c | d |'), tableRow( - sourceAttrs('0:9', '| a | b |'), - tableHeader(sourceAttrs('0:5', '| a |'), paragraph(sourceAttrs('2:3', 'a'), 'a')), - tableHeader(sourceAttrs('5:9', ' b |'), paragraph(sourceAttrs('6:7', 'b'), 'b')), + source('| a | b |'), + tableHeader(source('| a |'), paragraph(source('a'), 'a')), + tableHeader(source(' b |'), paragraph(source('b'), 'b')), ), tableRow( - sourceAttrs('20:29', '| c | d |'), - tableCell(sourceAttrs('20:25', '| c |'), paragraph(sourceAttrs('22:23', 'c'), 'c')), - tableCell(sourceAttrs('25:29', ' d |'), paragraph(sourceAttrs('26:27', 'd'), 'd')), + source('| c | d |'), + tableCell(source('| c |'), paragraph(source('c'), 'c')), + tableCell(source(' d |'), paragraph(source('d'), 'd')), ), ), ), @@ -936,30 +866,29 @@ const fn = () => 'GitLab'; `, expectedDoc: doc( table( - sourceAttrs( - '0:132', + source( '<table>\n <tr>\n <th colspan="2" rowspan="5">Header</th>\n </tr>\n <tr>\n <td colspan="2" rowspan="5">Body</td>\n </tr>\n</table>', ), tableRow( - sourceAttrs('10:66', '<tr>\n <th colspan="2" rowspan="5">Header</th>\n </tr>'), + source('<tr>\n <th colspan="2" rowspan="5">Header</th>\n </tr>'), tableHeader( { - ...sourceAttrs('19:58', '<th colspan="2" rowspan="5">Header</th>'), + ...source('<th colspan="2" rowspan="5">Header</th>'), colspan: 2, rowspan: 5, }, - paragraph(sourceAttrs('47:53', 'Header'), 'Header'), + paragraph(source('Header'), 'Header'), ), ), tableRow( - sourceAttrs('69:123', '<tr>\n <td colspan="2" rowspan="5">Body</td>\n </tr>'), + source('<tr>\n <td colspan="2" rowspan="5">Body</td>\n </tr>'), tableCell( { - ...sourceAttrs('78:115', '<td colspan="2" rowspan="5">Body</td>'), + ...source('<td colspan="2" rowspan="5">Body</td>'), colspan: 2, rowspan: 5, }, - paragraph(sourceAttrs('106:110', 'Body'), 'Body'), + paragraph(source('Body'), 'Body'), ), ), ), @@ -977,24 +906,177 @@ Paragraph `, expectedDoc: doc( paragraph( - sourceAttrs('0:30', 'This is a footnote [^footnote]'), + source('This is a footnote [^footnote]'), 'This is a footnote ', footnoteReference({ - ...sourceAttrs('19:30', '[^footnote]'), + ...source('[^footnote]'), identifier: 'footnote', label: 'footnote', }), ), - paragraph(sourceAttrs('32:41', 'Paragraph'), 'Paragraph'), + paragraph(source('Paragraph'), 'Paragraph'), footnoteDefinition( { - ...sourceAttrs('43:75', '[^footnote]: Footnote definition'), + ...source('[^footnote]: Footnote definition'), identifier: 'footnote', label: 'footnote', }, - paragraph(sourceAttrs('56:75', 'Footnote definition'), 'Footnote definition'), + paragraph(source('Footnote definition'), 'Footnote definition'), + ), + paragraph(source('Paragraph'), 'Paragraph'), + ), + }, + { + markdown: ` +<div>div</div> +`, + expectedDoc: doc(div(source('<div>div</div>'), paragraph(source('div'), 'div'))), + }, + { + markdown: ` +[![moon](moon.jpg)](/uri) +`, + expectedDoc: doc( + paragraph( + source('[![moon](moon.jpg)](/uri)'), + link( + { ...source('[![moon](moon.jpg)](/uri)'), href: '/uri' }, + image({ ...source('![moon](moon.jpg)'), src: 'moon.jpg', alt: 'moon' }), + ), + ), + ), + }, + { + markdown: ` +<del> + +*foo* + +</del> +`, + expectedDoc: doc( + paragraph( + source('*foo*'), + strike(source('<del>\n\n*foo*\n\n</del>'), italic(source('*foo*'), 'foo')), + ), + ), + expectedMarkdown: '*foo*', + }, + { + markdown: ` +~[moon](moon.jpg) and [sun](sun.jpg)~ +`, + expectedDoc: doc( + paragraph( + source('~[moon](moon.jpg) and [sun](sun.jpg)~'), + strike( + source('~[moon](moon.jpg) and [sun](sun.jpg)~'), + link({ ...source('[moon](moon.jpg)'), href: 'moon.jpg' }, 'moon'), + ), + strike(source('~[moon](moon.jpg) and [sun](sun.jpg)~'), ' and '), + strike( + source('~[moon](moon.jpg) and [sun](sun.jpg)~'), + link({ ...source('[sun](sun.jpg)'), href: 'sun.jpg' }, 'sun'), + ), + ), + ), + }, + { + markdown: ` +<del> + +**Paragraph 1** + +_Paragraph 2_ + +</del> + `, + expectedDoc: doc( + paragraph( + source('**Paragraph 1**'), + strike( + source('<del>\n\n**Paragraph 1**\n\n_Paragraph 2_\n\n</del>'), + bold(source('**Paragraph 1**'), 'Paragraph 1'), + ), + ), + paragraph( + source('_Paragraph 2_'), + strike( + source('<del>\n\n**Paragraph 1**\n\n_Paragraph 2_\n\n</del>'), + italic(source('_Paragraph 2_'), 'Paragraph 2'), + ), + ), + ), + expectedMarkdown: `**Paragraph 1** + +_Paragraph 2_`, + }, + /* TODO + * Implement proper editing support for HTML comments in the Content Editor + * https://gitlab.com/gitlab-org/gitlab/-/issues/342173 + */ + { + markdown: '<!-- HTML comment -->', + expectedDoc: doc(paragraph()), + expectedMarkdown: '', + }, + { + markdown: ` +<![CDATA[ +function matchwo(a,b) +{ + if (a < b && a < 0) then { + return 1; + + } else { + + return 0; + } +} +]]> + `, + expectedDoc: doc(paragraph()), + expectedMarkdown: '', + }, + { + markdown: ` +<!-- foo -->*bar* +*baz* + `, + expectedDoc: doc( + paragraph(source('*bar*'), '*bar*\n'), + paragraph(source('*baz*'), italic(source('*baz*'), 'baz')), + ), + expectedMarkdown: `*bar* + +*baz*`, + }, + { + markdown: ` +<table><tr><td> +<pre> +**Hello**, + +_world_. +</pre> +</td></tr></table> +`, + expectedDoc: doc( + table( + source('<table><tr><td>\n<pre>\n**Hello**,\n\n_world_.\n</pre>\n</td></tr></table>'), + tableRow( + source('<tr><td>\n<pre>\n**Hello**,\n\n_world_.\n</pre>\n</td></tr>'), + tableCell( + source('<td>\n<pre>\n**Hello**,\n\n_world_.\n</pre>\n</td>'), + pre( + source('<pre>\n**Hello**,\n\n_world_.\n</pre>'), + paragraph(source('**Hello**,'), '**Hello**,\n'), + paragraph(source('_world_.\n'), italic(source('_world_'), 'world'), '.\n'), + ), + paragraph(), + ), + ), ), - paragraph(sourceAttrs('77:86', 'Paragraph'), 'Paragraph'), ), }, ]; @@ -1002,12 +1084,75 @@ Paragraph const runOnly = examples.find((example) => example.only === true); const runExamples = runOnly ? [runOnly] : examples; - it.each(runExamples)('processes %s correctly', async ({ markdown, expectedDoc }) => { - const trimmed = markdown.trim(); - const document = await deserialize(trimmed); + it.each(runExamples)( + 'processes %s correctly', + async ({ markdown, expectedDoc, expectedMarkdown }) => { + const trimmed = markdown.trim(); + const document = await deserialize(trimmed); - expect(expectedDoc).not.toBeFalsy(); - expect(document.toJSON()).toEqual(expectedDoc.toJSON()); - expect(serialize(document)).toEqual(trimmed); - }); + expect(expectedDoc).not.toBeFalsy(); + expect(document.toJSON()).toEqual(expectedDoc.toJSON()); + expect(serialize(document)).toEqual(expectedMarkdown ?? trimmed); + }, + ); + + /** + * DISCLAIMER: THIS IS A SECURITY ORIENTED TEST THAT ENSURES + * THE CLIENT-SIDE PARSER IGNORES DANGEROUS TAGS THAT ARE NOT + * EXPLICITELY SUPPORTED. + * + * PLEASE CONSIDER THIS INFORMATION WHILE MODIFYING THESE TESTS + */ + it.each([ + { + markdown: ` +<script> +alert("Hello world") +</script> + `, + expectedHtml: '<p></p>', + }, + { + markdown: ` +<foo>Hello</foo> + `, + expectedHtml: '<p></p>', + }, + { + markdown: ` +<h1 class="heading-with-class">Header</h1> + `, + expectedHtml: '<h1>Header</h1>', + }, + { + markdown: ` +<a id="link-id">Header</a> and other text + `, + expectedHtml: + '<p><a target="_blank" rel="noopener noreferrer nofollow">Header</a> and other text</p>', + }, + { + markdown: ` +<style> +body { + display: none; +} +</style> + `, + expectedHtml: '<p></p>', + }, + { + markdown: '<div style="transform">div</div>', + expectedHtml: '<div><p>div</p></div>', + }, + ])( + 'removes unknown tags and unsupported attributes from HTML output', + async ({ markdown, expectedHtml }) => { + const document = await deserialize(markdown); + + tiptapEditor.commands.setContent(document.toJSON()); + + expect(tiptapEditor.getHTML()).toEqual(expectedHtml); + }, + ); }); diff --git a/spec/frontend/content_editor/render_html_and_json_for_all_examples.js b/spec/frontend/content_editor/render_html_and_json_for_all_examples.js new file mode 100644 index 00000000000..116a26cf7d5 --- /dev/null +++ b/spec/frontend/content_editor/render_html_and_json_for_all_examples.js @@ -0,0 +1,115 @@ +import { DOMSerializer } from 'prosemirror-model'; +// TODO: DRY up duplication with spec/frontend/content_editor/services/markdown_serializer_spec.js +// See https://gitlab.com/groups/gitlab-org/-/epics/7719#plan +import Blockquote from '~/content_editor/extensions/blockquote'; +import Bold from '~/content_editor/extensions/bold'; +import BulletList from '~/content_editor/extensions/bullet_list'; +import Code from '~/content_editor/extensions/code'; +import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; +import DescriptionItem from '~/content_editor/extensions/description_item'; +import DescriptionList from '~/content_editor/extensions/description_list'; +import Details from '~/content_editor/extensions/details'; +import DetailsContent from '~/content_editor/extensions/details_content'; +import Emoji from '~/content_editor/extensions/emoji'; +import Figure from '~/content_editor/extensions/figure'; +import FigureCaption from '~/content_editor/extensions/figure_caption'; +import FootnoteDefinition from '~/content_editor/extensions/footnote_definition'; +import FootnoteReference from '~/content_editor/extensions/footnote_reference'; +import FootnotesSection from '~/content_editor/extensions/footnotes_section'; +import HardBreak from '~/content_editor/extensions/hard_break'; +import Heading from '~/content_editor/extensions/heading'; +import HorizontalRule from '~/content_editor/extensions/horizontal_rule'; +import HTMLNodes from '~/content_editor/extensions/html_nodes'; +import Image from '~/content_editor/extensions/image'; +import InlineDiff from '~/content_editor/extensions/inline_diff'; +import Italic from '~/content_editor/extensions/italic'; +import Link from '~/content_editor/extensions/link'; +import ListItem from '~/content_editor/extensions/list_item'; +import OrderedList from '~/content_editor/extensions/ordered_list'; +import Strike from '~/content_editor/extensions/strike'; +import Table from '~/content_editor/extensions/table'; +import TableCell from '~/content_editor/extensions/table_cell'; +import TableHeader from '~/content_editor/extensions/table_header'; +import TableRow from '~/content_editor/extensions/table_row'; +import TaskItem from '~/content_editor/extensions/task_item'; +import TaskList from '~/content_editor/extensions/task_list'; +import createMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer'; +import { createTestEditor } from 'jest/content_editor/test_utils'; + +const tiptapEditor = createTestEditor({ + extensions: [ + Blockquote, + Bold, + BulletList, + Code, + CodeBlockHighlight, + DescriptionItem, + DescriptionList, + Details, + DetailsContent, + Emoji, + FootnoteDefinition, + FootnoteReference, + FootnotesSection, + Figure, + FigureCaption, + HardBreak, + Heading, + HorizontalRule, + ...HTMLNodes, + Image, + InlineDiff, + Italic, + Link, + ListItem, + OrderedList, + Strike, + Table, + TableCell, + TableHeader, + TableRow, + TaskItem, + TaskList, + ], +}); + +export const IMPLEMENTATION_ERROR_MSG = 'Error - check implementation'; + +async function renderMarkdownToHTMLAndJSON(markdown, schema, deserializer) { + let prosemirrorDocument; + try { + const { document } = await deserializer.deserialize({ schema, markdown }); + prosemirrorDocument = document; + } catch (e) { + const errorMsg = `${IMPLEMENTATION_ERROR_MSG}:\n${e.message}`; + return { + html: errorMsg, + json: errorMsg, + }; + } + + const documentFragment = DOMSerializer.fromSchema(schema).serializeFragment( + prosemirrorDocument.content, + ); + const htmlString = Array.from(documentFragment.children) + .map((el) => el.outerHTML) + .join('\n'); + + const json = prosemirrorDocument.toJSON(); + const jsonString = JSON.stringify(json, null, 2); + return { html: htmlString, json: jsonString }; +} + +export function renderHtmlAndJsonForAllExamples(markdownExamples) { + const { schema } = tiptapEditor; + const deserializer = createMarkdownDeserializer(); + const exampleNames = Object.keys(markdownExamples); + + return exampleNames.reduce(async (promisedExamples, exampleName) => { + const markdown = markdownExamples[exampleName]; + const htmlAndJson = await renderMarkdownToHTMLAndJSON(markdown, schema, deserializer); + const examples = await promisedExamples; + examples[exampleName] = htmlAndJson; + return examples; + }, Promise.resolve({})); +} diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 13e9efaea59..509cda3046c 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -7,7 +7,6 @@ import DescriptionItem from '~/content_editor/extensions/description_item'; import DescriptionList from '~/content_editor/extensions/description_list'; import Details from '~/content_editor/extensions/details'; import DetailsContent from '~/content_editor/extensions/details_content'; -import Division from '~/content_editor/extensions/division'; import Emoji from '~/content_editor/extensions/emoji'; import Figure from '~/content_editor/extensions/figure'; import FigureCaption from '~/content_editor/extensions/figure_caption'; @@ -16,6 +15,8 @@ import FootnoteReference from '~/content_editor/extensions/footnote_reference'; import HardBreak from '~/content_editor/extensions/hard_break'; import Heading from '~/content_editor/extensions/heading'; import HorizontalRule from '~/content_editor/extensions/horizontal_rule'; +import HTMLMarks from '~/content_editor/extensions/html_marks'; +import HTMLNodes from '~/content_editor/extensions/html_nodes'; import Image from '~/content_editor/extensions/image'; import InlineDiff from '~/content_editor/extensions/inline_diff'; import Italic from '~/content_editor/extensions/italic'; @@ -48,7 +49,6 @@ const tiptapEditor = createTestEditor({ DescriptionList, Details, DetailsContent, - Division, Emoji, FootnoteDefinition, FootnoteReference, @@ -71,6 +71,8 @@ const tiptapEditor = createTestEditor({ TableRow, TaskItem, TaskList, + ...HTMLMarks, + ...HTMLNodes, ], }); @@ -84,7 +86,7 @@ const { codeBlock, details, detailsContent, - division, + div, descriptionItem, descriptionList, emoji, @@ -120,7 +122,6 @@ const { codeBlock: { nodeType: CodeBlockHighlight.name }, details: { nodeType: Details.name }, detailsContent: { nodeType: DetailsContent.name }, - division: { nodeType: Division.name }, descriptionItem: { nodeType: DescriptionItem.name }, descriptionList: { nodeType: DescriptionList.name }, emoji: { markType: Emoji.name }, @@ -145,6 +146,13 @@ const { tableRow: { nodeType: TableRow.name }, taskItem: { nodeType: TaskItem.name }, taskList: { nodeType: TaskList.name }, + ...HTMLNodes.reduce( + (builders, htmlNode) => ({ + ...builders, + [htmlNode.name]: { nodeType: htmlNode.name }, + }), + {}, + ), }, }); @@ -725,8 +733,8 @@ _inception_ it('correctly renders div', () => { expect( serialize( - division(paragraph('just a paragraph in a div')), - division(paragraph('just some ', bold('styled'), ' ', italic('content'), ' in a div')), + div(paragraph('just a paragraph in a div')), + div(paragraph('just some ', bold('styled'), ' ', italic('content'), ' in a div')), ), ).toBe( '<div>just a paragraph in a div</div>\n<div>\n\njust some **styled** _content_ in a div\n\n</div>', @@ -1169,7 +1177,7 @@ Oranges are orange [^1] }; it.each` - mark | content | modifiedContent | editAction + mark | markdown | modifiedMarkdown | editAction ${'bold'} | ${'**bold**'} | ${'**bold modified**'} | ${defaultEditAction} ${'bold'} | ${'__bold__'} | ${'__bold modified__'} | ${defaultEditAction} ${'bold'} | ${'<strong>bold</strong>'} | ${'<strong>bold modified</strong>'} | ${defaultEditAction} @@ -1205,10 +1213,10 @@ Oranges are orange [^1] ${'taskList'} | ${'2) [x] task list item'} | ${'2) [x] task list item modified'} | ${defaultEditAction} `( 'preserves original $mark syntax when sourceMarkdown is available for $content', - async ({ content, modifiedContent, editAction }) => { + async ({ markdown, modifiedMarkdown, editAction }) => { const { document } = await remarkMarkdownDeserializer().deserialize({ schema: tiptapEditor.schema, - content, + markdown, }); editAction(document); @@ -1218,7 +1226,7 @@ Oranges are orange [^1] doc: tiptapEditor.state.doc, }); - expect(serialized).toEqual(modifiedContent); + expect(serialized).toEqual(modifiedMarkdown); }, ); }); diff --git a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js index 8a304c73163..2efc73ddef8 100644 --- a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js +++ b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js @@ -77,7 +77,7 @@ describe('content_editor/services/markdown_sourcemap', () => { render: () => BULLET_LIST_HTML, }).deserialize({ schema: tiptapEditor.schema, - content: BULLET_LIST_MARKDOWN, + markdown: BULLET_LIST_MARKDOWN, }); const expected = doc( diff --git a/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js b/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js index 2001f5c1441..bd4ed950f9d 100644 --- a/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js +++ b/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js @@ -1,3 +1,4 @@ +import { nextTick } from 'vue'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; @@ -148,6 +149,7 @@ describe('custom metrics form fields component', () => { it('expect loading message to display', async () => { const queryInput = wrapper.find(`input[name="${queryInputName}"]`); queryInput.setValue('query'); + await nextTick(); expect(wrapper.text()).toContain('Validating query'); }); diff --git a/spec/frontend/cycle_analytics/path_navigation_spec.js b/spec/frontend/cycle_analytics/path_navigation_spec.js index e8c4ebd3a38..fa9eadbd071 100644 --- a/spec/frontend/cycle_analytics/path_navigation_spec.js +++ b/spec/frontend/cycle_analytics/path_navigation_spec.js @@ -85,7 +85,7 @@ describe('Project PathNavigation', () => { const result = findPathNavigationTitles(); transformedProjectStagePathData.forEach(({ title, metric }, index) => { expect(result[index]).toContain(title); - expect(result[index]).toContain(metric); + expect(result[index]).toContain(metric.toString()); }); }); diff --git a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js index df86b10cba3..23e41f35b00 100644 --- a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js +++ b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js @@ -55,10 +55,10 @@ describe('ValueStreamMetrics', () => { describe('with successful requests', () => { beforeEach(() => { mockGetValueStreamSummaryMetrics = jest.fn().mockResolvedValue({ data: metricsData }); - wrapper = createComponent(); }); it('will display a loader with pending requests', async () => { + wrapper = createComponent(); await nextTick(); expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true); @@ -66,6 +66,7 @@ describe('ValueStreamMetrics', () => { describe('with data loaded', () => { beforeEach(async () => { + wrapper = createComponent(); await waitForPromises(); }); diff --git a/spec/frontend/design_management/components/design_sidebar_spec.js b/spec/frontend/design_management/components/design_sidebar_spec.js index 40968d9204a..f13796138bd 100644 --- a/spec/frontend/design_management/components/design_sidebar_spec.js +++ b/spec/frontend/design_management/components/design_sidebar_spec.js @@ -1,7 +1,6 @@ -import { GlCollapse, GlPopover } from '@gitlab/ui'; +import { GlAccordionItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; -import Cookies from '~/lib/utils/cookies'; 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'; @@ -27,8 +26,6 @@ const $route = { }, }; -const cookieKey = 'hide_design_resolved_comments_popover'; - const mutate = jest.fn().mockResolvedValue(); describe('Design management design sidebar component', () => { @@ -40,9 +37,7 @@ describe('Design management design sidebar component', () => { const findUnresolvedDiscussions = () => wrapper.findAll('[data-testid="unresolved-discussion"]'); const findResolvedDiscussions = () => wrapper.findAll('[data-testid="resolved-discussion"]'); const findParticipants = () => wrapper.find(Participants); - const findCollapsible = () => wrapper.find(GlCollapse); - const findToggleResolvedCommentsButton = () => wrapper.find('[data-testid="resolved-comments"]'); - const findPopover = () => wrapper.find(GlPopover); + const findResolvedCommentsToggle = () => wrapper.find(GlAccordionItem); const findNewDiscussionDisclaimer = () => wrapper.find('[data-testid="new-discussion-disclaimer"]'); @@ -61,7 +56,6 @@ describe('Design management design sidebar component', () => { mutate, }, }, - stubs: { GlPopover }, provide: { registerPath: '/users/sign_up?redirect_to_referer=yes', signInPath: '/users/sign_in?redirect_to_referer=yes', @@ -119,7 +113,6 @@ describe('Design management design sidebar component', () => { describe('when has discussions', () => { beforeEach(() => { - Cookies.set(cookieKey, true); createComponent(); }); @@ -131,26 +124,23 @@ describe('Design management design sidebar component', () => { expect(findResolvedDiscussions()).toHaveLength(1); }); - it('has resolved comments collapsible collapsed', () => { - expect(findCollapsible().attributes('visible')).toBeUndefined(); + it('has resolved comments accordion item collapsed', () => { + expect(findResolvedCommentsToggle().props('visible')).toBe(false); }); - it('emits toggleResolveComments event on resolve comments button click', () => { - findToggleResolvedCommentsButton().vm.$emit('click'); + it('emits toggleResolveComments event on resolve comments button click', async () => { + findResolvedCommentsToggle().vm.$emit('input', true); + await nextTick(); expect(wrapper.emitted('toggleResolvedComments')).toHaveLength(1); }); - it('opens a collapsible when resolvedDiscussionsExpanded prop changes to true', async () => { - expect(findCollapsible().attributes('visible')).toBeUndefined(); + it('opens the accordion item when resolvedDiscussionsExpanded prop changes to true', async () => { + expect(findResolvedCommentsToggle().props('visible')).toBe(false); wrapper.setProps({ resolvedDiscussionsExpanded: true, }); await nextTick(); - expect(findCollapsible().attributes('visible')).toBe('true'); - }); - - it('does not popover about resolved comments', () => { - expect(findPopover().exists()).toBe(false); + expect(findResolvedCommentsToggle().props('visible')).toBe(true); }); it('sends a mutation to set an active discussion when clicking on a discussion', () => { @@ -232,36 +222,6 @@ describe('Design management design sidebar component', () => { }); }); - describe('when showing resolved discussions for the first time', () => { - beforeEach(() => { - Cookies.set(cookieKey, false); - createComponent(); - }); - - it('renders a popover if we show resolved comments collapsible for the first time', () => { - expect(findPopover().exists()).toBe(true); - }); - - it('scrolls to resolved threads link', () => { - expect(scrollIntoViewMock).toHaveBeenCalled(); - }); - - it('dismisses a popover on the outside click', async () => { - wrapper.trigger('click'); - await nextTick(); - expect(findPopover().exists()).toBe(false); - }); - - it(`sets a ${cookieKey} cookie on clicking outside the popover`, () => { - jest.spyOn(Cookies, 'set'); - wrapper.trigger('click'); - expect(Cookies.set).toHaveBeenCalledWith(cookieKey, 'true', { - expires: 365 * 10, - secure: false, - }); - }); - }); - describe('when user is not logged in', () => { const findDesignNoteSignedOut = () => wrapper.findComponent(DesignNoteSignedOut); @@ -292,7 +252,6 @@ describe('Design management design sidebar component', () => { describe('design has discussions', () => { beforeEach(() => { - Cookies.set(cookieKey, true); createComponent(); }); diff --git a/spec/frontend/design_management/components/image_spec.js b/spec/frontend/design_management/components/image_spec.js index e27b2bc9fa5..65ee0ae6238 100644 --- a/spec/frontend/design_management/components/image_spec.js +++ b/spec/frontend/design_management/components/image_spec.js @@ -1,6 +1,7 @@ import { GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import { stubPerformanceWebAPI } from 'helpers/performance'; import DesignImage from '~/design_management/components/image.vue'; describe('Design management large image component', () => { @@ -15,6 +16,10 @@ describe('Design management large image component', () => { wrapper.setData(data); } + beforeEach(() => { + stubPerformanceWebAPI(); + }); + afterEach(() => { wrapper.destroy(); }); diff --git a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap index be736184e60..9997f02cd01 100644 --- a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap @@ -7,6 +7,8 @@ exports[`Design management index page designs renders error 1`] = ` > <!----> + <!----> + <div class="gl-mt-6" > @@ -39,6 +41,8 @@ exports[`Design management index page designs renders loading icon 1`] = ` > <!----> + <!----> + <div class="gl-mt-6" > 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 0f2857821ea..3177a5e016c 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 @@ -88,57 +88,26 @@ exports[`Design management design index page renders design index 1`] = ` signinpath="" /> - <gl-button-stub - buttontextclasses="" - category="primary" - class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-mb-4" - data-testid="resolved-comments" - icon="chevron-right" - id="resolved-comments" - size="medium" - variant="link" + <gl-accordion-stub + class="gl-mb-5" + headerlevel="3" > - Resolved Comments (1) - - </gl-button-stub> - - <gl-popover-stub - container="popovercontainer" - cssclasses="" - placement="top" - show="true" - target="resolved-comments" - title="Resolved Comments" - > - <p> - - Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below - - </p> - - <a - href="https://docs.gitlab.com/ee/user/project/issues/design_management.html#resolve-design-threads" - rel="noopener noreferrer" - target="_blank" + <gl-accordion-item-stub + headerclass="gl-mb-5!" + title="Resolved Comments (1)" > - Learn more about resolving comments - </a> - </gl-popover-stub> - - <gl-collapse-stub - class="gl-mt-3" - > - <design-discussion-stub - data-testid="resolved-discussion" - designid="gid::/gitlab/Design/1" - discussion="[object Object]" - discussionwithopenform="" - markdownpreviewpath="/project-path/preview_markdown?target_type=Issue" - noteableid="gid::/gitlab/Design/1" - registerpath="" - signinpath="" - /> - </gl-collapse-stub> + <design-discussion-stub + data-testid="resolved-discussion" + designid="gid::/gitlab/Design/1" + discussion="[object Object]" + discussionwithopenform="" + markdownpreviewpath="/project-path/preview_markdown?target_type=Issue" + noteableid="gid::/gitlab/Design/1" + registerpath="" + signinpath="" + /> + </gl-accordion-item-stub> + </gl-accordion-stub> </div> </div> diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index 087655d10f7..21be7bd148b 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -1,5 +1,4 @@ import { GlEmptyState } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo, { ApolloMutation } from 'vue-apollo'; @@ -9,6 +8,7 @@ import VueDraggable from 'vuedraggable'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql'; import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql'; import DeleteButton from '~/design_management/components/delete_button.vue'; @@ -23,6 +23,7 @@ import * as utils from '~/design_management/utils/design_management_utils'; import { EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE, EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE, + UPLOAD_DESIGN_ERROR, } from '~/design_management/utils/error_messages'; import { DESIGN_TRACKING_PAGE_NAME, @@ -101,20 +102,20 @@ describe('Design management index page', () => { let moveDesignHandler; const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox'); - const findSelectAllButton = () => wrapper.find('[data-testid="select-all-designs-button"'); - const findToolbar = () => wrapper.find('.qa-selector-toolbar'); - const findDesignCollectionIsCopying = () => - wrapper.find('[data-testid="design-collection-is-copying"'); - const findDeleteButton = () => wrapper.find(DeleteButton); - const findDropzone = () => wrapper.findAll(DesignDropzone).at(0); + const findSelectAllButton = () => wrapper.findByTestId('select-all-designs-button'); + const findToolbar = () => wrapper.findByTestId('design-selector-toolbar'); + const findDesignCollectionIsCopying = () => wrapper.findByTestId('design-collection-is-copying'); + const findDeleteButton = () => wrapper.findComponent(DeleteButton); + const findDropzone = () => wrapper.findAllComponents(DesignDropzone).at(0); const dropzoneClasses = () => findDropzone().classes(); - const findDropzoneWrapper = () => wrapper.find('[data-testid="design-dropzone-wrapper"]'); - const findFirstDropzoneWithDesign = () => wrapper.findAll(DesignDropzone).at(1); - const findDesignsWrapper = () => wrapper.find('[data-testid="designs-root"]'); + const findDropzoneWrapper = () => wrapper.findByTestId('design-dropzone-wrapper'); + const findFirstDropzoneWithDesign = () => wrapper.findAllComponents(DesignDropzone).at(1); + const findDesignsWrapper = () => wrapper.findByTestId('designs-root'); const findDesigns = () => wrapper.findAll(Design); const draggableAttributes = () => wrapper.find(VueDraggable).vm.$attrs; - const findDesignUploadButton = () => wrapper.find('[data-testid="design-upload-button"]'); - const findDesignToolbarWrapper = () => wrapper.find('[data-testid="design-toolbar-wrapper"]'); + const findDesignUploadButton = () => wrapper.findByTestId('design-upload-button'); + const findDesignToolbarWrapper = () => wrapper.findByTestId('design-toolbar-wrapper'); + const findDesignUpdateAlert = () => wrapper.findByTestId('design-update-alert'); async function moveDesigns(localWrapper) { await waitForPromises(); @@ -149,7 +150,7 @@ describe('Design management index page', () => { mutate, }; - wrapper = shallowMount(Index, { + wrapper = shallowMountExtended(Index, { data() { return { allVersions, @@ -185,7 +186,7 @@ describe('Design management index page', () => { ]; fakeApollo = createMockApollo(requestHandlers, {}, { addTypename: true }); - wrapper = shallowMount(Index, { + wrapper = shallowMountExtended(Index, { apolloProvider: fakeApollo, router, stubs: { VueDraggable }, @@ -412,7 +413,8 @@ describe('Design management index page', () => { await nextTick(); expect(wrapper.vm.filesToBeSaved).toEqual([]); expect(wrapper.vm.isSaving).toBeFalsy(); - expect(createFlash).toHaveBeenCalled(); + expect(findDesignUpdateAlert().exists()).toBe(true); + expect(findDesignUpdateAlert().text()).toBe(UPLOAD_DESIGN_ERROR); }); it('does not call mutation if createDesign is false', () => { @@ -431,19 +433,23 @@ describe('Design management index page', () => { wrapper.vm.onUploadDesign(new Array(MAXIMUM_FILE_UPLOAD_LIMIT).fill(mockDesigns[0])); - expect(createFlash).not.toHaveBeenCalled(); + expect(findDesignUpdateAlert().exists()).toBe(false); }); - it('warns when too many files are uploaded', () => { + it('warns when too many files are uploaded', async () => { createComponent(); wrapper.vm.onUploadDesign(new Array(MAXIMUM_FILE_UPLOAD_LIMIT + 1).fill(mockDesigns[0])); + await nextTick(); - expect(createFlash).toHaveBeenCalled(); + expect(findDesignUpdateAlert().exists()).toBe(true); + expect(findDesignUpdateAlert().text()).toBe( + 'The maximum number of designs allowed to be uploaded is 10. Please try again.', + ); }); }); - it('flashes warning if designs are skipped', async () => { + it('displays warning if designs are skipped', async () => { createComponent({ mockMutate: () => Promise.resolve({ @@ -458,11 +464,8 @@ describe('Design management index page', () => { ]); await uploadDesign; - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ - message: 'Upload skipped. test.jpg did not change.', - types: 'warning', - }); + expect(findDesignUpdateAlert().exists()).toBe(true); + expect(findDesignUpdateAlert().text()).toBe('Upload skipped. test.jpg did not change.'); }); describe('dragging onto an existing design', () => { @@ -495,13 +498,17 @@ describe('Design management index page', () => { description | eventPayload | message ${'> 1 file'} | ${[{ name: 'test' }, { name: 'test-2' }]} | ${EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE} ${'different filename'} | ${[{ name: 'wrong-name' }]} | ${EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE} - `('calls createFlash when upload has $description', ({ eventPayload, message }) => { - const designDropzone = findFirstDropzoneWithDesign(); - designDropzone.vm.$emit('change', eventPayload); - - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ message }); - }); + `( + 'displays GlAlert component when upload has $description', + async ({ eventPayload, message }) => { + expect(findDesignUpdateAlert().exists()).toBe(false); + const designDropzone = findFirstDropzoneWithDesign(); + await designDropzone.vm.$emit('change', eventPayload); + + expect(findDesignUpdateAlert().exists()).toBe(true); + expect(findDesignUpdateAlert().text()).toBe(message); + }, + ); }); describe('tracking', () => { @@ -804,7 +811,7 @@ describe('Design management index page', () => { expect(createFlash).toHaveBeenCalledWith({ message: 'Houston, we have a problem' }); }); - it('displays flash if mutation had a non-recoverable error', async () => { + it('displays alert if mutation had a non-recoverable error', async () => { createComponentWithApollo({ moveHandler: jest.fn().mockRejectedValue('Error'), }); @@ -812,9 +819,10 @@ describe('Design management index page', () => { await moveDesigns(wrapper); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ - message: 'Something went wrong when reordering designs. Please try again', - }); + expect(findDesignUpdateAlert().exists()).toBe(true); + expect(findDesignUpdateAlert().text()).toBe( + 'Something went wrong when reordering designs. Please try again', + ); }); }); }); diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index 76e4a944d87..96f2ac1692c 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -18,6 +18,7 @@ import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue'; import axios from '~/lib/utils/axios_utils'; import * as urlUtils from '~/lib/utils/url_utility'; +import { stubPerformanceWebAPI } from 'helpers/performance'; import createDiffsStore from '../create_diffs_store'; import diffsMockData from '../mock_data/merge_request_diffs'; @@ -79,6 +80,7 @@ describe('diffs/components/app', () => { } beforeEach(() => { + stubPerformanceWebAPI(); // setup globals (needed for component to mount :/) window.mrTabs = { resetViewContainer: jest.fn(), diff --git a/spec/frontend/diffs/components/collapsed_files_warning_spec.js b/spec/frontend/diffs/components/collapsed_files_warning_spec.js index 8cc342e45a7..cc4f13ab0cf 100644 --- a/spec/frontend/diffs/components/collapsed_files_warning_spec.js +++ b/spec/frontend/diffs/components/collapsed_files_warning_spec.js @@ -6,7 +6,7 @@ import { EVT_EXPAND_ALL_FILES } from '~/diffs/constants'; import eventHub from '~/diffs/event_hub'; import createStore from '~/diffs/store/modules'; -import file from '../mock_data/diff_file'; +import { getDiffFileMock } from '../mock_data/diff_file'; const propsData = { limited: true, @@ -15,7 +15,7 @@ const propsData = { }; async function files(store, count) { - const copies = Array(count).fill(file); + const copies = Array(count).fill(getDiffFileMock()); store.state.diffs.diffFiles.push(...copies); await nextTick(); diff --git a/spec/frontend/diffs/components/diff_code_quality_spec.js b/spec/frontend/diffs/components/diff_code_quality_spec.js new file mode 100644 index 00000000000..81a817c47dc --- /dev/null +++ b/spec/frontend/diffs/components/diff_code_quality_spec.js @@ -0,0 +1,66 @@ +import { GlIcon } from '@gitlab/ui'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import DiffCodeQuality from '~/diffs/components/diff_code_quality.vue'; +import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/reports/codequality_report/constants'; +import { multipleFindingsArr } from '../mock_data/diff_code_quality'; + +let wrapper; + +const findIcon = () => wrapper.findComponent(GlIcon); + +describe('DiffCodeQuality', () => { + afterEach(() => { + wrapper.destroy(); + }); + + const createWrapper = (codeQuality, mountFunction = mountExtended) => { + return mountFunction(DiffCodeQuality, { + propsData: { + expandedLines: [], + line: 1, + codeQuality, + }, + }); + }; + + it('hides details and throws hideCodeQualityFindings event on close click', async () => { + wrapper = createWrapper(multipleFindingsArr); + expect(wrapper.findByTestId('diff-codequality').exists()).toBe(true); + + await wrapper.findByTestId('diff-codequality-close').trigger('click'); + + expect(wrapper.emitted('hideCodeQualityFindings').length).toBe(1); + expect(wrapper.emitted().hideCodeQualityFindings[0][0]).toBe(wrapper.props('line')); + }); + + it('renders correct amount of list items for codequality array and their description', async () => { + wrapper = createWrapper(multipleFindingsArr); + const listItems = wrapper.findAll('li'); + + expect(wrapper.findAll('li').length).toBe(3); + + listItems.wrappers.map((e, i) => { + return expect(e.text()).toEqual(multipleFindingsArr[i].description); + }); + }); + + it.each` + severity + ${'info'} + ${'minor'} + ${'major'} + ${'critical'} + ${'blocker'} + ${'unknown'} + `('shows icon for $severity degradation', ({ severity }) => { + wrapper = createWrapper([{ severity }], shallowMountExtended); + + expect(findIcon().exists()).toBe(true); + + expect(findIcon().attributes()).toMatchObject({ + class: `codequality-severity-icon ${SEVERITY_CLASSES[severity]}`, + name: SEVERITY_ICONS[severity], + size: '12', + }); + }); +}); diff --git a/spec/frontend/diffs/components/diff_content_spec.js b/spec/frontend/diffs/components/diff_content_spec.js index 7d2afe105a5..6844e6e497a 100644 --- a/spec/frontend/diffs/components/diff_content_spec.js +++ b/spec/frontend/diffs/components/diff_content_spec.js @@ -10,7 +10,7 @@ import { diffViewerModes } from '~/ide/constants'; import NoteForm from '~/notes/components/note_form.vue'; import NoPreviewViewer from '~/vue_shared/components/diff_viewer/viewers/no_preview.vue'; import NotDiffableViewer from '~/vue_shared/components/diff_viewer/viewers/not_diffable.vue'; -import diffFileMockData from '../mock_data/diff_file'; +import { getDiffFileMock } from '../mock_data/diff_file'; Vue.use(Vuex); @@ -28,7 +28,7 @@ describe('DiffContent', () => { const getCommentFormForDiffFileGetterMock = jest.fn(); const defaultProps = { - diffFile: JSON.parse(JSON.stringify(diffFileMockData)), + diffFile: getDiffFileMock(), }; const createComponent = ({ props, state, provide } = {}) => { @@ -70,7 +70,7 @@ describe('DiffContent', () => { isInlineView: isInlineViewGetterMock, isParallelView: isParallelViewGetterMock, getCommentFormForDiffFile: getCommentFormForDiffFileGetterMock, - diffLines: () => () => [...diffFileMockData.parallel_diff_lines], + diffLines: () => () => [...getDiffFileMock().parallel_diff_lines], fileLineCodequality: () => () => [], }, actions: { diff --git a/spec/frontend/diffs/components/diff_expansion_cell_spec.js b/spec/frontend/diffs/components/diff_expansion_cell_spec.js index 5ff0728b358..34bb73ccf26 100644 --- a/spec/frontend/diffs/components/diff_expansion_cell_spec.js +++ b/spec/frontend/diffs/components/diff_expansion_cell_spec.js @@ -1,10 +1,9 @@ import { mount } from '@vue/test-utils'; -import { cloneDeep } from 'lodash'; import DiffExpansionCell from '~/diffs/components/diff_expansion_cell.vue'; import { INLINE_DIFF_VIEW_TYPE } from '~/diffs/constants'; import { getPreviousLineIndex } from '~/diffs/store/utils'; import { createStore } from '~/mr_notes/stores'; -import diffFileMockData from '../mock_data/diff_file'; +import { getDiffFileMock } from '../mock_data/diff_file'; const EXPAND_UP_CLASS = '.js-unfold'; const EXPAND_DOWN_CLASS = '.js-unfold-down'; @@ -26,7 +25,7 @@ function makeLoadMoreLinesPayload({ isExpandDown = false, }) { return { - endpoint: diffFileMockData.context_lines_path, + endpoint: getDiffFileMock().context_lines_path, params: { since: sinceLine, to: toLine, @@ -57,7 +56,7 @@ describe('DiffExpansionCell', () => { let store; beforeEach(() => { - mockFile = cloneDeep(diffFileMockData); + mockFile = getDiffFileMock(); mockLine = getLine(mockFile, INLINE_DIFF_VIEW_TYPE, 8); store = createStore(); store.state.diffs.diffFiles = [mockFile]; @@ -117,102 +116,102 @@ describe('DiffExpansionCell', () => { }); describe('any row', () => { - [ - { diffViewType: INLINE_DIFF_VIEW_TYPE, lineIndex: 8, file: cloneDeep(diffFileMockData) }, - ].forEach(({ diffViewType, file, lineIndex }) => { - describe(`with diffViewType (${diffViewType})`, () => { - beforeEach(() => { - mockLine = getLine(mockFile, diffViewType, lineIndex); - store.state.diffs.diffFiles = [{ ...mockFile, ...file }]; - store.state.diffs.diffViewType = diffViewType; - }); - - it('does not initially dispatch anything', () => { - expect(store.dispatch).not.toHaveBeenCalled(); - }); - - it('on expand all clicked, dispatch loadMoreLines', () => { - const oldLineNumber = mockLine.meta_data.old_pos; - const newLineNumber = mockLine.meta_data.new_pos; - const previousIndex = getPreviousLineIndex(mockFile, { - oldLineNumber, - newLineNumber, + [{ diffViewType: INLINE_DIFF_VIEW_TYPE, lineIndex: 8, file: getDiffFileMock() }].forEach( + ({ diffViewType, file, lineIndex }) => { + describe(`with diffViewType (${diffViewType})`, () => { + beforeEach(() => { + mockLine = getLine(mockFile, diffViewType, lineIndex); + store.state.diffs.diffFiles = [{ ...mockFile, ...file }]; + store.state.diffs.diffViewType = diffViewType; }); - const wrapper = createComponent({ file, lineCountBetween: 10 }); - - findExpandAll(wrapper).trigger('click'); + it('does not initially dispatch anything', () => { + expect(store.dispatch).not.toHaveBeenCalled(); + }); - expect(store.dispatch).toHaveBeenCalledWith( - 'diffs/loadMoreLines', - makeLoadMoreLinesPayload({ - fileHash: mockFile.file_hash, - toLine: newLineNumber - 1, - sinceLine: previousIndex, + it('on expand all clicked, dispatch loadMoreLines', () => { + const oldLineNumber = mockLine.meta_data.old_pos; + const newLineNumber = mockLine.meta_data.new_pos; + const previousIndex = getPreviousLineIndex(mockFile, { oldLineNumber, - }), - ); - }); + newLineNumber, + }); + + const wrapper = createComponent({ file, lineCountBetween: 10 }); + + findExpandAll(wrapper).trigger('click'); + + expect(store.dispatch).toHaveBeenCalledWith( + 'diffs/loadMoreLines', + makeLoadMoreLinesPayload({ + fileHash: mockFile.file_hash, + toLine: newLineNumber - 1, + sinceLine: previousIndex, + oldLineNumber, + }), + ); + }); - it('on expand up clicked, dispatch loadMoreLines', () => { - mockLine.meta_data.old_pos = 200; - mockLine.meta_data.new_pos = 200; + it('on expand up clicked, dispatch loadMoreLines', () => { + mockLine.meta_data.old_pos = 200; + mockLine.meta_data.new_pos = 200; - const oldLineNumber = mockLine.meta_data.old_pos; - const newLineNumber = mockLine.meta_data.new_pos; + const oldLineNumber = mockLine.meta_data.old_pos; + const newLineNumber = mockLine.meta_data.new_pos; - const wrapper = createComponent({ file }); + const wrapper = createComponent({ file }); - findExpandUp(wrapper).trigger('click'); + findExpandUp(wrapper).trigger('click'); - expect(store.dispatch).toHaveBeenCalledWith( - 'diffs/loadMoreLines', - makeLoadMoreLinesPayload({ - fileHash: mockFile.file_hash, - toLine: newLineNumber - 1, - sinceLine: 179, - oldLineNumber, - diffViewType, - unfold: true, - }), - ); - }); + expect(store.dispatch).toHaveBeenCalledWith( + 'diffs/loadMoreLines', + makeLoadMoreLinesPayload({ + fileHash: mockFile.file_hash, + toLine: newLineNumber - 1, + sinceLine: 179, + oldLineNumber, + diffViewType, + unfold: true, + }), + ); + }); - it('on expand down clicked, dispatch loadMoreLines', () => { - mockFile[lineSources[diffViewType]][lineIndex + 1] = cloneDeep( - mockFile[lineSources[diffViewType]][lineIndex], - ); - const nextLine = getLine(mockFile, diffViewType, lineIndex + 1); - - nextLine.meta_data.old_pos = 300; - nextLine.meta_data.new_pos = 300; - mockLine.meta_data.old_pos = 200; - mockLine.meta_data.new_pos = 200; - - const wrapper = createComponent({ file }); - - findExpandDown(wrapper).trigger('click'); - - expect(store.dispatch).toHaveBeenCalledWith('diffs/loadMoreLines', { - endpoint: diffFileMockData.context_lines_path, - params: { - since: 1, - to: 21, // the load amount, plus 1 line - offset: 0, - unfold: true, - bottom: true, - }, - lineNumbers: { - // when expanding down, these are based on the previous line, 0, in this case - oldLineNumber: 0, - newLineNumber: 0, - }, - nextLineNumbers: { old_line: 200, new_line: 200 }, - fileHash: mockFile.file_hash, - isExpandDown: true, + it('on expand down clicked, dispatch loadMoreLines', () => { + mockFile[lineSources[diffViewType]][lineIndex + 1] = getDiffFileMock()[ + lineSources[diffViewType] + ][lineIndex]; + const nextLine = getLine(mockFile, diffViewType, lineIndex + 1); + + nextLine.meta_data.old_pos = 300; + nextLine.meta_data.new_pos = 300; + mockLine.meta_data.old_pos = 200; + mockLine.meta_data.new_pos = 200; + + const wrapper = createComponent({ file }); + + findExpandDown(wrapper).trigger('click'); + + expect(store.dispatch).toHaveBeenCalledWith('diffs/loadMoreLines', { + endpoint: mockFile.context_lines_path, + params: { + since: 1, + to: 21, // the load amount, plus 1 line + offset: 0, + unfold: true, + bottom: true, + }, + lineNumbers: { + // when expanding down, these are based on the previous line, 0, in this case + oldLineNumber: 0, + newLineNumber: 0, + }, + nextLineNumbers: { old_line: 200, new_line: 200 }, + fileHash: mockFile.file_hash, + isExpandDown: true, + }); }); }); - }); - }); + }, + ); }); }); diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js index a0aa4c784bf..9e8d9e1ca29 100644 --- a/spec/frontend/diffs/components/diff_file_spec.js +++ b/spec/frontend/diffs/components/diff_file_spec.js @@ -20,7 +20,7 @@ import axios from '~/lib/utils/axios_utils'; import { scrollToElement } from '~/lib/utils/common_utils'; import httpStatus from '~/lib/utils/http_status'; import createNotesStore from '~/notes/stores/modules'; -import diffFileMockDataReadable from '../mock_data/diff_file'; +import { getDiffFileMock } from '../mock_data/diff_file'; import diffFileMockDataUnreadable from '../mock_data/diff_file_unreadable'; jest.mock('~/lib/utils/common_utils'); @@ -106,7 +106,7 @@ const findLoader = (wrapper) => wrapper.find('[data-testid="loader-icon"]'); const findToggleButton = (wrapper) => wrapper.find('[data-testid="expand-button"]'); const toggleFile = (wrapper) => findDiffHeader(wrapper).vm.$emit('toggleFile'); -const getReadableFile = () => JSON.parse(JSON.stringify(diffFileMockDataReadable)); +const getReadableFile = () => getDiffFileMock(); const getUnreadableFile = () => JSON.parse(JSON.stringify(diffFileMockDataUnreadable)); const makeFileAutomaticallyCollapsed = (store, index = 0) => diff --git a/spec/frontend/diffs/components/diff_line_note_form_spec.js b/spec/frontend/diffs/components/diff_line_note_form_spec.js index b59043168b8..542d61c4680 100644 --- a/spec/frontend/diffs/components/diff_line_note_form_spec.js +++ b/spec/frontend/diffs/components/diff_line_note_form_spec.js @@ -1,200 +1,207 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import Vuex from 'vuex'; +import Autosave from '~/autosave'; import DiffLineNoteForm from '~/diffs/components/diff_line_note_form.vue'; -import { createStore } from '~/mr_notes/stores'; +import { createModules } from '~/mr_notes/stores'; import NoteForm from '~/notes/components/note_form.vue'; +import MultilineCommentForm from '~/notes/components/multiline_comment_form.vue'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { noteableDataMock } from 'jest/notes/mock_data'; -import diffFileMockData from '../mock_data/diff_file'; +import { getDiffFileMock } from '../mock_data/diff_file'; -jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal', () => { - return { - confirmAction: jest.fn(), - }; -}); +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); +jest.mock('~/autosave'); describe('DiffLineNoteForm', () => { let wrapper; let diffFile; let diffLines; - const getDiffFileMock = () => ({ ...diffFileMockData }); + let actions; + let store; - const createComponent = (args = {}) => { - diffFile = getDiffFileMock(); - diffLines = diffFile.highlighted_diff_lines; - const store = createStore(); + const getSelectedLine = () => { + const lineCode = diffLines[1].line_code; + return diffFile.highlighted_diff_lines.find((l) => l.line_code === lineCode); + }; + + const createStore = (state) => { + const modules = createModules(); + modules.diffs.actions = { + ...modules.diffs.actions, + saveDiffDiscussion: jest.fn(() => Promise.resolve()), + }; + modules.diffs.getters = { + ...modules.diffs.getters, + diffCompareDropdownTargetVersions: jest.fn(), + diffCompareDropdownSourceVersions: jest.fn(), + selectedSourceIndex: jest.fn(), + }; + modules.notes.getters = { + ...modules.notes.getters, + noteableType: jest.fn(), + }; + actions = modules.diffs.actions; + + store = new Vuex.Store({ modules }); store.state.notes.userData.id = 1; store.state.notes.noteableData = noteableDataMock; + + store.replaceState({ ...store.state, ...state }); + }; + + const createComponent = ({ props, state } = {}) => { + wrapper?.destroy(); + diffFile = getDiffFileMock(); + diffLines = diffFile.highlighted_diff_lines; + + createStore(state); store.state.diffs.diffFiles = [diffFile]; - store.replaceState({ ...store.state, ...args.state }); + const propsData = { + diffFileHash: diffFile.file_hash, + diffLines, + line: diffLines[1], + range: { start: diffLines[0], end: diffLines[1] }, + noteTargetLine: diffLines[1], + ...props, + }; - return shallowMount(DiffLineNoteForm, { + wrapper = shallowMount(DiffLineNoteForm, { store, - propsData: { - ...{ - diffFileHash: diffFile.file_hash, - diffLines, - line: diffLines[1], - range: { start: diffLines[0], end: diffLines[1] }, - noteTargetLine: diffLines[1], - }, - ...(args.props || {}), - }, + propsData, }); }; const findNoteForm = () => wrapper.findComponent(NoteForm); + const findCommentForm = () => wrapper.findComponent(MultilineCommentForm); - describe('methods', () => { - beforeEach(() => { - wrapper = createComponent(); - }); - - describe('handleCancelCommentForm', () => { - afterEach(() => { - confirmAction.mockReset(); - }); - - it('should ask for confirmation when shouldConfirm and isDirty passed as truthy', () => { - confirmAction.mockResolvedValueOnce(false); - - findNoteForm().vm.$emit('cancelForm', true, true); - - expect(confirmAction).toHaveBeenCalled(); - }); + beforeEach(() => { + Autosave.mockClear(); + createComponent(); + }); - it('should only ask for confirmation once', () => { - // Never resolve so we can test what happens when triggered while "confirmAction" is loading - confirmAction.mockImplementation(() => new Promise(() => {})); + it('shows note form', () => { + expect(wrapper.find(NoteForm).exists()).toBe(true); + }); - findNoteForm().vm.$emit('cancelForm', true, true); - findNoteForm().vm.$emit('cancelForm', true, true); + it('passes the provided range of lines to comment form', () => { + expect(findCommentForm().props('lineRange')).toMatchObject({ + start: diffLines[0], + end: diffLines[1], + }); + }); - expect(confirmAction).toHaveBeenCalledTimes(1); - }); + it('respects empty range when passing a range of lines', () => { + createComponent({ props: { range: null } }); + expect(findCommentForm().props('lineRange')).toMatchObject({ + start: diffLines[1], + end: diffLines[1], + }); + }); - it('should not ask for confirmation when one of the params false', () => { - confirmAction.mockResolvedValueOnce(false); + it('should init autosave', () => { + expect(Autosave).toHaveBeenCalledWith({}, [ + 'Note', + 'Issue', + 98, + undefined, + 'DiffNote', + undefined, + '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_2', + ]); + }); - findNoteForm().vm.$emit('cancelForm', true, false); + describe('when cancelling form', () => { + afterEach(() => { + confirmAction.mockReset(); + }); - expect(confirmAction).not.toHaveBeenCalled(); + it('should only ask for confirmation once', () => { + let finalizePromise; + confirmAction.mockImplementation( + () => + new Promise((resolve) => { + finalizePromise = resolve; + }), + ); - findNoteForm().vm.$emit('cancelForm', false, true); + findNoteForm().vm.$emit('cancelForm', true, true); + findNoteForm().vm.$emit('cancelForm', true, true); - expect(confirmAction).not.toHaveBeenCalled(); - }); + expect(confirmAction).toHaveBeenCalledTimes(1); + finalizePromise(); + }); - it('should call cancelCommentForm with lineCode', async () => { + describe('with confirmation', () => { + beforeEach(() => { confirmAction.mockResolvedValueOnce(true); - jest.spyOn(wrapper.vm, 'cancelCommentForm').mockImplementation(() => {}); - jest.spyOn(wrapper.vm, 'resetAutoSave').mockImplementation(() => {}); + }); + it('should ask form confirmation and hide form for a line', async () => { findNoteForm().vm.$emit('cancelForm', true, true); - await nextTick(); - expect(confirmAction).toHaveBeenCalled(); - await nextTick(); - expect(wrapper.vm.cancelCommentForm).toHaveBeenCalledWith({ - lineCode: diffLines[1].line_code, - fileHash: wrapper.vm.diffFileHash, - }); - expect(wrapper.vm.resetAutoSave).toHaveBeenCalled(); + expect(getSelectedLine().hasForm).toBe(false); + expect(Autosave.mock.instances[0].reset).toHaveBeenCalled(); }); }); - describe('saveNoteForm', () => { - it('should call saveNote action with proper params', async () => { - const saveDiffDiscussionSpy = jest - .spyOn(wrapper.vm, 'saveDiffDiscussion') - .mockReturnValue(Promise.resolve()); - - const lineRange = { - start: { - line_code: wrapper.vm.commentLineStart.line_code, - type: wrapper.vm.commentLineStart.type, - new_line: 2, - old_line: null, - }, - end: { - line_code: wrapper.vm.line.line_code, - type: wrapper.vm.line.type, - new_line: 2, - old_line: null, - }, - }; - - const formData = { - ...wrapper.vm.formData, - lineRange, - }; - - await wrapper.vm.handleSaveNote('note body'); - expect(saveDiffDiscussionSpy).toHaveBeenCalledWith({ - note: 'note body', - formData, - }); + describe('without confirmation', () => { + beforeEach(() => { + confirmAction.mockResolvedValueOnce(false); }); - }); - }); - describe('created', () => { - it('should use the provided `range` of lines', () => { - wrapper = createComponent(); + it('should ask for confirmation when shouldConfirm and isDirty passed as truthy', () => { + findNoteForm().vm.$emit('cancelForm', true, true); - expect(wrapper.vm.lines.start).toBe(diffLines[0]); - expect(wrapper.vm.lines.end).toBe(diffLines[1]); - }); + expect(confirmAction).toHaveBeenCalled(); + }); - it("should fill the internal `lines` data with the provided `line` if there's no provided `range", () => { - wrapper = createComponent({ props: { range: null } }); + it('should not ask for confirmation when one of the params false', () => { + findNoteForm().vm.$emit('cancelForm', true, false); - expect(wrapper.vm.lines.start).toBe(diffLines[1]); - expect(wrapper.vm.lines.end).toBe(diffLines[1]); - }); - }); + expect(confirmAction).not.toHaveBeenCalled(); - describe('mounted', () => { - it('should init autosave', () => { - const key = 'autosave/Note/Issue/98//DiffNote//1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_2'; - wrapper = createComponent(); + findNoteForm().vm.$emit('cancelForm', false, true); - expect(wrapper.vm.autosave).toBeDefined(); - expect(wrapper.vm.autosave.key).toEqual(key); + expect(confirmAction).not.toHaveBeenCalled(); + }); }); + }); - it('should set selectedCommentPosition', () => { - wrapper = createComponent(); - let startLineCode = wrapper.vm.commentLineStart.line_code; - let lineCode = wrapper.vm.line.line_code; - - expect(startLineCode).toEqual(lineCode); - wrapper.destroy(); - - const state = { - notes: { - selectedCommentPosition: { - start: { - line_code: 'test', - }, - }, + describe('saving note', () => { + it('should save original line', async () => { + const lineRange = { + start: { + line_code: diffLines[1].line_code, + type: diffLines[1].type, + new_line: 2, + old_line: null, + }, + end: { + line_code: diffLines[1].line_code, + type: diffLines[1].type, + new_line: 2, + old_line: null, }, }; - wrapper = createComponent({ state }); - startLineCode = wrapper.vm.commentLineStart.line_code; - lineCode = state.notes.selectedCommentPosition.start.line_code; - expect(startLineCode).toEqual(lineCode); + await findNoteForm().vm.$emit('handleFormUpdate', 'note body'); + expect(actions.saveDiffDiscussion.mock.calls[0][1].formData).toMatchObject({ + lineRange, + }); }); - }); - describe('template', () => { - it('should have note form', () => { - wrapper = createComponent(); - expect(wrapper.find(NoteForm).exists()).toBe(true); + it('should save selected line from the store', async () => { + const lineCode = 'test'; + store.state.notes.selectedCommentPosition = { start: { line_code: lineCode } }; + createComponent({ state: store.state }); + await findNoteForm().vm.$emit('handleFormUpdate', 'note body'); + expect(actions.saveDiffDiscussion.mock.calls[0][1].formData.lineRange.start.line_code).toBe( + lineCode, + ); }); }); }); diff --git a/spec/frontend/diffs/components/diff_row_spec.js b/spec/frontend/diffs/components/diff_row_spec.js index 4c5ce429c9d..be81508213b 100644 --- a/spec/frontend/diffs/components/diff_row_spec.js +++ b/spec/frontend/diffs/components/diff_row_spec.js @@ -6,7 +6,7 @@ import DiffRow from '~/diffs/components/diff_row.vue'; import { mapParallel } from '~/diffs/components/diff_row_utils'; import diffsModule from '~/diffs/store/modules'; import { findInteropAttributes } from '../find_interop_attributes'; -import diffFileMockData from '../mock_data/diff_file'; +import { getDiffFileMock } from '../mock_data/diff_file'; const showCommentForm = jest.fn(); const enterdragging = jest.fn(); @@ -210,6 +210,7 @@ describe('DiffRow', () => { }); describe('sets coverage title and class', () => { + const diffFileMockData = getDiffFileMock(); const thisLine = diffFileMockData.parallel_diff_lines[2]; const rightLine = diffFileMockData.parallel_diff_lines[2].right; diff --git a/spec/frontend/diffs/components/diff_stats_spec.js b/spec/frontend/diffs/components/diff_stats_spec.js index 4ef1ec55cb0..09fe69e97de 100644 --- a/spec/frontend/diffs/components/diff_stats_spec.js +++ b/spec/frontend/diffs/components/diff_stats_spec.js @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import DiffStats from '~/diffs/components/diff_stats.vue'; -import mockDiffFile from '../mock_data/diff_file'; +import { getDiffFileMock } from '../mock_data/diff_file'; const TEST_ADDED_LINES = 100; const TEST_REMOVED_LINES = 200; @@ -48,6 +48,7 @@ describe('diff_stats', () => { const getBytesContainer = () => wrapper.find('.diff-stats > div:first-child'); beforeEach(() => { + const mockDiffFile = getDiffFileMock(); file = { ...mockDiffFile, viewer: { diff --git a/spec/frontend/diffs/components/diff_view_spec.js b/spec/frontend/diffs/components/diff_view_spec.js index dfbe30e460b..15923a1c6de 100644 --- a/spec/frontend/diffs/components/diff_view_spec.js +++ b/spec/frontend/diffs/components/diff_view_spec.js @@ -1,7 +1,9 @@ import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import DiffView from '~/diffs/components/diff_view.vue'; +import DiffCodeQuality from '~/diffs/components/diff_code_quality.vue'; +import { diffCodeQuality } from '../mock_data/diff_code_quality'; describe('DiffView', () => { const DiffExpansionCell = { template: `<div/>` }; @@ -12,7 +14,7 @@ describe('DiffView', () => { const setSelectedCommentPosition = jest.fn(); const getDiffRow = (wrapper) => wrapper.findComponent(DiffRow).vm; - const createWrapper = (props) => { + const createWrapper = (props, provide = {}) => { Vue.use(Vuex); const batchComments = { @@ -46,9 +48,33 @@ describe('DiffView', () => { ...props, }; const stubs = { DiffExpansionCell, DiffRow, DiffCommentCell, DraftNote }; - return shallowMount(DiffView, { propsData, store, stubs }); + return shallowMount(DiffView, { propsData, store, stubs, provide }); }; + it('does not render a codeQuality diff view when there is no finding', () => { + const wrapper = createWrapper(); + expect(wrapper.findComponent(DiffCodeQuality).exists()).toBe(false); + }); + + it('does render a codeQuality diff view with the correct props when there is a finding & refactorCodeQualityInlineFindings flag is true ', async () => { + const wrapper = createWrapper(diffCodeQuality, { + glFeatures: { refactorCodeQualityInlineFindings: true }, + }); + wrapper.findComponent(DiffRow).vm.$emit('toggleCodeQualityFindings', 2); + await nextTick(); + expect(wrapper.findComponent(DiffCodeQuality).exists()).toBe(true); + expect(wrapper.findComponent(DiffCodeQuality).props().codeQuality.length).not.toBe(0); + }); + + it('does not render a codeQuality diff view when there is a finding & refactorCodeQualityInlineFindings flag is false ', async () => { + const wrapper = createWrapper(diffCodeQuality, { + glFeatures: { refactorCodeQualityInlineFindings: false }, + }); + wrapper.findComponent(DiffRow).vm.$emit('toggleCodeQualityFindings', 2); + await nextTick(); + expect(wrapper.findComponent(DiffCodeQuality).exists()).toBe(false); + }); + it.each` type | side | container | sides | total ${'parallel'} | ${'left'} | ${'.old'} | ${{ left: { lineDraft: {}, renderDiscussion: true }, right: { lineDraft: {}, renderDiscussion: true } }} | ${2} diff --git a/spec/frontend/diffs/components/settings_dropdown_spec.js b/spec/frontend/diffs/components/settings_dropdown_spec.js index 693fc5bfd8f..2ec11ba86fd 100644 --- a/spec/frontend/diffs/components/settings_dropdown_spec.js +++ b/spec/frontend/diffs/components/settings_dropdown_spec.js @@ -1,6 +1,5 @@ import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import SettingsDropdown from '~/diffs/components/settings_dropdown.vue'; @@ -139,9 +138,7 @@ describe('Diff settings dropdown component', () => { const checkbox = wrapper.findByTestId('show-whitespace'); const { checked } = checkbox.element; - checkbox.trigger('click'); - - await nextTick(); + await checkbox.setChecked(false); expect(store.dispatch).toHaveBeenCalledWith('diffs/setShowWhitespace', { showWhitespace: !checked, @@ -182,9 +179,7 @@ describe('Diff settings dropdown component', () => { Object.assign(origStore.state.diffs, { viewDiffsFileByFile: start }), }); - getFileByFileCheckbox(wrapper).trigger('click'); - - await nextTick(); + await getFileByFileCheckbox(wrapper).setChecked(setting); expect(store.dispatch).toHaveBeenCalledWith('diffs/setFileByFile', { fileByFile: setting, diff --git a/spec/frontend/diffs/components/tree_list_spec.js b/spec/frontend/diffs/components/tree_list_spec.js index 963805f4792..931a9562d36 100644 --- a/spec/frontend/diffs/components/tree_list_spec.js +++ b/spec/frontend/diffs/components/tree_list_spec.js @@ -50,6 +50,19 @@ describe('Diffs tree list component', () => { type: 'blob', parentPath: 'app', }, + 'test.rb': { + addedLines: 0, + changed: true, + deleted: false, + fileHash: 'test', + key: 'test.rb', + name: 'test.rb', + path: 'app/test.rb', + removedLines: 0, + tempFile: true, + type: 'blob', + parentPath: 'app', + }, app: { key: 'app', path: 'app', @@ -85,6 +98,23 @@ describe('Diffs tree list component', () => { createComponent(); }); + describe('search by file extension', () => { + it.each` + extension | itemSize + ${'*.md'} | ${0} + ${'*.js'} | ${1} + ${'index.js'} | ${1} + ${'app/*.js'} | ${1} + ${'*.js, *.rb'} | ${2} + `('it returns $itemSize item for $extension', async ({ extension, itemSize }) => { + wrapper.find('[data-testid="diff-tree-search"]').setValue(extension); + + await nextTick(); + + expect(getFileRows()).toHaveLength(itemSize); + }); + }); + it('renders tree', () => { expect(getFileRows()).toHaveLength(2); expect(getFileRows().at(0).html()).toContain('index.js'); @@ -120,7 +150,7 @@ describe('Diffs tree list component', () => { wrapper.vm.$store.state.diffs.renderTreeList = false; await nextTick(); - expect(getFileRows()).toHaveLength(1); + expect(getFileRows()).toHaveLength(2); }); it('renders file paths when renderTreeList is false', async () => { diff --git a/spec/frontend/diffs/mock_data/diff_code_quality.js b/spec/frontend/diffs/mock_data/diff_code_quality.js new file mode 100644 index 00000000000..2ca421a20b4 --- /dev/null +++ b/spec/frontend/diffs/mock_data/diff_code_quality.js @@ -0,0 +1,62 @@ +export const multipleFindingsArr = [ + { + severity: 'minor', + description: 'Unexpected Debugger Statement.', + line: 2, + }, + { + severity: 'major', + description: + 'Function `aVeryLongFunction` has 52 lines of code (exceeds 25 allowed). Consider refactoring.', + line: 3, + }, + { + severity: 'minor', + description: 'Arrow function has too many statements (52). Maximum allowed is 30.', + line: 3, + }, +]; + +export const multipleFindings = { + filePath: 'index.js', + codequality: multipleFindingsArr, +}; + +export const singularFinding = { + filePath: 'index.js', + codequality: [multipleFindingsArr[0]], +}; + +export const diffCodeQuality = { + diffFile: { file_hash: '123' }, + diffLines: [ + { + left: { + type: 'old', + old_line: 1, + new_line: null, + codequality: [], + lineDraft: {}, + }, + }, + { + left: { + type: null, + old_line: 2, + new_line: 1, + codequality: [], + lineDraft: {}, + }, + }, + { + left: { + type: 'new', + old_line: null, + new_line: 2, + + codequality: [multipleFindingsArr[0]], + lineDraft: {}, + }, + }, + ], +}; diff --git a/spec/frontend/diffs/mock_data/diff_file.js b/spec/frontend/diffs/mock_data/diff_file.js index 9ebcd5ef26b..dd200b0248c 100644 --- a/spec/frontend/diffs/mock_data/diff_file.js +++ b/spec/frontend/diffs/mock_data/diff_file.js @@ -1,4 +1,4 @@ -export default { +export const getDiffFileMock = () => ({ submodule: false, submodule_link: null, blob: { @@ -305,4 +305,4 @@ export default { ], discussions: [], renderingLines: false, -}; +}); diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index cc595e58dda..346e43e5a72 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -3,7 +3,7 @@ import Cookies from '~/lib/utils/cookies'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; -import mockDiffFile from 'jest/diffs/mock_data/diff_file'; +import { getDiffFileMock } from 'jest/diffs/mock_data/diff_file'; import { DIFF_VIEW_COOKIE_NAME, INLINE_DIFF_VIEW_TYPE, @@ -754,7 +754,7 @@ describe('DiffsStoreActions', () => { it('dispatches actions', () => { const commitId = 'something'; const formData = { - diffFile: { ...mockDiffFile }, + diffFile: getDiffFileMock(), noteableData: {}, }; const note = {}; diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js index 57e623b843d..031e4fe2be2 100644 --- a/spec/frontend/diffs/store/mutations_spec.js +++ b/spec/frontend/diffs/store/mutations_spec.js @@ -3,7 +3,7 @@ import createState from '~/diffs/store/modules/diff_state'; import * as types from '~/diffs/store/mutation_types'; import mutations from '~/diffs/store/mutations'; import * as utils from '~/diffs/store/utils'; -import diffFileMockData from '../mock_data/diff_file'; +import { getDiffFileMock } from '../mock_data/diff_file'; describe('DiffsStoreMutations', () => { describe('SET_BASE_CONFIG', () => { @@ -71,6 +71,7 @@ describe('DiffsStoreMutations', () => { describe('SET_DIFF_METADATA', () => { it('should overwrite state with the camelCased data that is passed in', () => { + const diffFileMockData = getDiffFileMock(); const state = { diffFiles: [], }; @@ -94,7 +95,7 @@ describe('DiffsStoreMutations', () => { it('should set diff data batch type properly', () => { const state = { diffFiles: [] }; const diffMock = { - diff_files: [diffFileMockData], + diff_files: [getDiffFileMock()], }; mutations[types.SET_DIFF_DATA_BATCH](state, diffMock); diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js index 6f55f76d7b5..8852c6c62c5 100644 --- a/spec/frontend/diffs/store/utils_spec.js +++ b/spec/frontend/diffs/store/utils_spec.js @@ -1,4 +1,3 @@ -import { clone } from 'lodash'; import { LINE_POSITION_LEFT, LINE_POSITION_RIGHT, @@ -14,10 +13,9 @@ import { import * as utils from '~/diffs/store/utils'; import { MERGE_REQUEST_NOTEABLE_TYPE } from '~/notes/constants'; import { noteableDataMock } from 'jest/notes/mock_data'; -import diffFileMockData from '../mock_data/diff_file'; +import { getDiffFileMock } from '../mock_data/diff_file'; import { diffMetadata } from '../mock_data/diff_metadata'; -const getDiffFileMock = () => JSON.parse(JSON.stringify(diffFileMockData)); const getDiffMetadataMock = () => JSON.parse(JSON.stringify(diffMetadata)); describe('DiffsStoreUtils', () => { @@ -47,7 +45,7 @@ describe('DiffsStoreUtils', () => { let diffFile; beforeEach(() => { - diffFile = { ...clone(diffFileMockData) }; + diffFile = getDiffFileMock(); }); it('should return the correct previous line number', () => { diff --git a/spec/frontend/diffs/utils/diff_file_spec.js b/spec/frontend/diffs/utils/diff_file_spec.js index 778897be3ba..b062a156216 100644 --- a/spec/frontend/diffs/utils/diff_file_spec.js +++ b/spec/frontend/diffs/utils/diff_file_spec.js @@ -6,7 +6,7 @@ import { match, } from '~/diffs/utils/diff_file'; import { diffViewerModes } from '~/ide/constants'; -import mockDiffFile from '../mock_data/diff_file'; +import { getDiffFileMock } from '../mock_data/diff_file'; function getDiffFiles() { const loadFull = 'namespace/project/-/merge_requests/12345/diff_for_path?file_identifier=abc'; @@ -210,7 +210,7 @@ describe('diff_file utilities', () => { ]; const validFile = [ 'computes the correct stats from a file', - mockDiffFile, + getDiffFileMock(), { changed: 1024, percent: 100, @@ -223,7 +223,7 @@ describe('diff_file utilities', () => { const negativeChange = [ 'computed the correct states from a file with a negative size change', { - ...mockDiffFile, + ...getDiffFileMock(), new_size: 0, old_size: 1024, }, diff --git a/spec/frontend/dirty_submit/dirty_submit_form_spec.js b/spec/frontend/dirty_submit/dirty_submit_form_spec.js index bcbe824bd9f..f24bb7374a3 100644 --- a/spec/frontend/dirty_submit/dirty_submit_form_spec.js +++ b/spec/frontend/dirty_submit/dirty_submit_form_spec.js @@ -55,7 +55,6 @@ describe('DirtySubmitForm', () => { describe('throttling tests', () => { beforeEach(() => { throttle.mockImplementation(lodash.throttle); - jest.useFakeTimers(); }); afterEach(() => { diff --git a/spec/frontend/emoji/awards_app/store/actions_spec.js b/spec/frontend/emoji/awards_app/store/actions_spec.js index 0761256ed23..cd3dfab30d4 100644 --- a/spec/frontend/emoji/awards_app/store/actions_spec.js +++ b/spec/frontend/emoji/awards_app/store/actions_spec.js @@ -8,10 +8,6 @@ jest.mock('@sentry/browser'); jest.mock('~/vue_shared/plugins/global_toast'); describe('Awards app actions', () => { - afterEach(() => { - window.gon = {}; - }); - describe('setInitialData', () => { it('commits SET_INITIAL_DATA', async () => { await testAction( @@ -52,8 +48,6 @@ describe('Awards app actions', () => { }); it('commits FETCH_AWARDS_SUCCESS', async () => { - window.gon.current_user_id = 1; - await testAction( actions.fetchAwards, '1', @@ -62,10 +56,6 @@ describe('Awards app actions', () => { [{ type: 'fetchAwards', payload: '2' }], ); }); - - it('does not commit FETCH_AWARDS_SUCCESS when user signed out', async () => { - await testAction(actions.fetchAwards, '1', { path: '/awards' }, [], []); - }); }); }); @@ -75,8 +65,6 @@ describe('Awards app actions', () => { }); it('calls Sentry.captureException', async () => { - window.gon = { current_user_id: 1 }; - await testAction(actions.fetchAwards, null, { path: '/awards' }, [], [], () => { expect(Sentry.captureException).toHaveBeenCalled(); }); diff --git a/spec/frontend/environment.js b/spec/frontend/environment.js index 8465b57c660..dc1c1dfbe4a 100644 --- a/spec/frontend/environment.js +++ b/spec/frontend/environment.js @@ -67,12 +67,6 @@ class CustomEnvironment extends JSDOMEnvironment { // Expose the jsdom (created in super class) to the global so that we can call reconfigure({ url: '' }) to properly set `window.location` this.global.jsdom = this.dom; - Object.assign(this.global.performance, { - mark: () => null, - measure: () => null, - getEntriesByName: () => [], - }); - // // Monaco-related environment variables // diff --git a/spec/frontend/environments/canary_update_modal_spec.js b/spec/frontend/environments/canary_update_modal_spec.js index 22d13558a84..16792dcda1e 100644 --- a/spec/frontend/environments/canary_update_modal_spec.js +++ b/spec/frontend/environments/canary_update_modal_spec.js @@ -47,7 +47,7 @@ describe('/environments/components/canary_update_modal.vue', () => { modalId: 'confirm-canary-change', actionPrimary: { text: 'Change ratio', - attributes: [{ variant: 'info' }], + attributes: [{ variant: 'confirm' }], }, actionCancel: { text: 'Cancel' }, }); diff --git a/spec/frontend/environments/confirm_rollback_modal_spec.js b/spec/frontend/environments/confirm_rollback_modal_spec.js index b8dcb7c0d08..c4763933468 100644 --- a/spec/frontend/environments/confirm_rollback_modal_spec.js +++ b/spec/frontend/environments/confirm_rollback_modal_spec.js @@ -2,6 +2,7 @@ import { GlModal, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; +import { trimText } from 'helpers/text_helper'; import ConfirmRollbackModal from '~/environments/components/confirm_rollback_modal.vue'; import createMockApollo from 'helpers/mock_apollo_helper'; import eventHub from '~/environments/event_hub'; @@ -76,9 +77,9 @@ describe('Confirm Rollback Modal Component', () => { expect(modal.attributes('title')).toContain('Rollback'); expect(modal.attributes('title')).toContain('test'); - expect(modal.props('actionPrimary').text).toBe('Rollback'); + expect(modal.props('actionPrimary').text).toBe('Rollback environment'); expect(modal.props('actionPrimary').attributes).toEqual(primaryPropsAttrs); - expect(modal.text()).toContain('commit abc0123'); + expect(trimText(modal.text())).toContain('commit abc0123'); expect(modal.text()).toContain('Are you sure you want to continue?'); }); @@ -95,8 +96,8 @@ describe('Confirm Rollback Modal Component', () => { expect(modal.attributes('title')).toContain('Re-deploy'); expect(modal.attributes('title')).toContain('test'); - expect(modal.props('actionPrimary').text).toBe('Re-deploy'); - expect(modal.text()).toContain('commit abc0123'); + expect(modal.props('actionPrimary').text).toBe('Re-deploy environment'); + expect(trimText(modal.text())).toContain('commit abc0123'); expect(modal.text()).toContain('Are you sure you want to continue?'); }); @@ -156,7 +157,7 @@ describe('Confirm Rollback Modal Component', () => { ); const modal = component.find(GlModal); - expect(modal.text()).toContain('commit abc0123'); + expect(trimText(modal.text())).toContain('commit abc0123'); expect(modal.text()).toContain('Are you sure you want to continue?'); }); @@ -180,7 +181,7 @@ describe('Confirm Rollback Modal Component', () => { expect(modal.attributes('title')).toContain('Rollback'); expect(modal.attributes('title')).toContain('test'); - expect(modal.props('actionPrimary').text).toBe('Rollback'); + expect(modal.props('actionPrimary').text).toBe('Rollback environment'); expect(modal.props('actionPrimary').attributes).toEqual(primaryPropsAttrs); }); @@ -204,7 +205,7 @@ describe('Confirm Rollback Modal Component', () => { expect(modal.attributes('title')).toContain('Re-deploy'); expect(modal.attributes('title')).toContain('test'); - expect(modal.props('actionPrimary').text).toBe('Re-deploy'); + expect(modal.props('actionPrimary').text).toBe('Re-deploy environment'); }); it('should commit the "rollback" mutation when "ok" is clicked', async () => { diff --git a/spec/frontend/environments/deploy_board_component_spec.js b/spec/frontend/environments/deploy_board_component_spec.js index 6bf87f7b07f..4d63648dd48 100644 --- a/spec/frontend/environments/deploy_board_component_spec.js +++ b/spec/frontend/environments/deploy_board_component_spec.js @@ -3,11 +3,9 @@ import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import CanaryIngress from '~/environments/components/canary_ingress.vue'; import DeployBoard from '~/environments/components/deploy_board.vue'; -import { deployBoardMockData, environment } from './mock_data'; +import { deployBoardMockData } from './mock_data'; import { rolloutStatus } from './graphql/mock_data'; -const logsPath = `gitlab-org/gitlab-test/-/logs?environment_name=${environment.name}`; - describe('Deploy Board', () => { let wrapper; @@ -17,7 +15,6 @@ describe('Deploy Board', () => { deployBoardData: deployBoardMockData, isLoading: false, isEmpty: false, - logsPath, ...props, }, }); @@ -132,7 +129,6 @@ describe('Deploy Board', () => { deployBoardData: {}, isLoading: false, isEmpty: true, - logsPath, }); return nextTick(); }); @@ -151,7 +147,6 @@ describe('Deploy Board', () => { deployBoardData: {}, isLoading: true, isEmpty: false, - logsPath, }); return nextTick(); }); @@ -167,7 +162,6 @@ describe('Deploy Board', () => { wrapper = createComponent({ isLoading: false, isEmpty: false, - logsPath: environment.log_path, deployBoardData: deployBoardMockData, }); ({ statuses } = wrapper.vm); diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js index 0761d04229c..1c86a66d9b8 100644 --- a/spec/frontend/environments/environment_item_spec.js +++ b/spec/frontend/environments/environment_item_spec.js @@ -68,7 +68,7 @@ describe('Environment item', () => { describe('With deployment', () => { it('should render deployment internal id', () => { expect(wrapper.find('.deployment-column span').text()).toContain( - environment.last_deployment.iid, + environment.last_deployment.iid.toString(), ); expect(wrapper.find('.deployment-column span').text()).toContain('#'); @@ -400,7 +400,7 @@ describe('Environment item', () => { }); it('should render the number of children in a badge', () => { - expect(wrapper.find('.folder-name .badge').text()).toContain(folder.size); + expect(wrapper.find('.folder-name .badge').text()).toContain(folder.size.toString()); }); it('should not render the "Upcoming deployment" column', () => { diff --git a/spec/frontend/environments/environment_table_spec.js b/spec/frontend/environments/environment_table_spec.js index 666e87c748e..aff6b1327f0 100644 --- a/spec/frontend/environments/environment_table_spec.js +++ b/spec/frontend/environments/environment_table_spec.js @@ -64,7 +64,6 @@ describe('Environment table', () => { name: 'review', size: 1, environment_path: 'url', - logs_path: 'url', id: 1, hasDeployBoard: true, deployBoardData: deployBoardMockData, @@ -92,7 +91,6 @@ describe('Environment table', () => { name: 'review', size: 1, environment_path: 'url', - logs_path: 'url', id: 1, isFolder: true, isOpen: true, @@ -161,7 +159,6 @@ describe('Environment table', () => { name: 'review', size: 1, environment_path: 'url', - logs_path: 'url', id: 1, hasDeployBoard: true, deployBoardData: deployBoardMockData, diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js index 91b75c850bd..57f98c81124 100644 --- a/spec/frontend/environments/environments_app_spec.js +++ b/spec/frontend/environments/environments_app_spec.js @@ -204,9 +204,9 @@ describe('~/environments/components/environments_app.vue', () => { const [available, stopped] = wrapper.findAllByRole('tab').wrappers; expect(available.text()).toContain(__('Available')); - expect(available.text()).toContain(resolvedEnvironmentsApp.availableCount); + expect(available.text()).toContain(resolvedEnvironmentsApp.availableCount.toString()); expect(stopped.text()).toContain(__('Stopped')); - expect(stopped.text()).toContain(resolvedEnvironmentsApp.stoppedCount); + expect(stopped.text()).toContain(resolvedEnvironmentsApp.stoppedCount.toString()); }); it('should change the requested scope on tab change', async () => { 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 5e0f0ca9bef..23d448f3964 100644 --- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js +++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js @@ -398,6 +398,30 @@ describe('ErrorTrackingList', () => { }); describe('When pagination is required', () => { + describe('and previous cursor is not available', () => { + beforeEach(async () => { + store.state.list.loading = false; + delete store.state.list.pagination.previous; + mountComponent(); + }); + + it('disables Prev button in the pagination', async () => { + expect(findPagination().props('prevPage')).toBe(null); + expect(findPagination().props('nextPage')).not.toBe(null); + }); + }); + describe('and next cursor is not available', () => { + beforeEach(async () => { + store.state.list.loading = false; + delete store.state.list.pagination.next; + mountComponent(); + }); + + it('disables Next button in the pagination', async () => { + expect(findPagination().props('prevPage')).not.toBe(null); + expect(findPagination().props('nextPage')).toBe(null); + }); + }); describe('and the user is not on the first page', () => { describe('and the previous button is clicked', () => { beforeEach(async () => { diff --git a/spec/frontend/fixtures/api_deploy_keys.rb b/spec/frontend/fixtures/api_deploy_keys.rb index 7027b8c975b..5ffc726f086 100644 --- a/spec/frontend/fixtures/api_deploy_keys.rb +++ b/spec/frontend/fixtures/api_deploy_keys.rb @@ -11,6 +11,7 @@ RSpec.describe API::DeployKeys, '(JavaScript fixtures)', type: :request do let_it_be(:project2) { create(:project) } let_it_be(:deploy_key) { create(:deploy_key, public: true) } let_it_be(:deploy_key2) { create(:deploy_key, public: true) } + let_it_be(:deploy_key_without_fingerprint) { create(:deploy_key, :without_md5_fingerprint, public: true) } let_it_be(:deploy_keys_project) { create(:deploy_keys_project, :write_access, project: project, deploy_key: deploy_key) } let_it_be(:deploy_keys_project2) { create(:deploy_keys_project, :write_access, project: project2, deploy_key: deploy_key) } let_it_be(:deploy_keys_project3) { create(:deploy_keys_project, :write_access, project: project, deploy_key: deploy_key2) } diff --git a/spec/frontend/fixtures/blob.rb b/spec/frontend/fixtures/blob.rb index af548823886..b2bbdd2749e 100644 --- a/spec/frontend/fixtures/blob.rb +++ b/spec/frontend/fixtures/blob.rb @@ -12,7 +12,6 @@ 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/deploy_keys.rb b/spec/frontend/fixtures/deploy_keys.rb index bed6c798793..154084e0181 100644 --- a/spec/frontend/fixtures/deploy_keys.rb +++ b/spec/frontend/fixtures/deploy_keys.rb @@ -27,9 +27,9 @@ RSpec.describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :c render_views it 'deploy_keys/keys.json' do - create(:rsa_deploy_key_2048, public: true) - project_key = create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCdMHEHyhRjbhEZVddFn6lTWdgEy5Q6Bz4nwGB76xWZI5YT/1WJOMEW+sL5zYd31kk7sd3FJ5L9ft8zWMWrr/iWXQikC2cqZK24H1xy+ZUmrRuJD4qGAaIVoyyzBL+avL+lF8J5lg6YSw8gwJY/lX64/vnJHUlWw2n5BF8IFOWhiw== dummy@gitlab.com') - internal_key = create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDNd/UJWhPrpb+b/G5oL109y57yKuCxE+WUGJGYaj7WQKsYRJmLYh1mgjrl+KVyfsWpq4ylOxIfFSnN9xBBFN8mlb0Fma5DC7YsSsibJr3MZ19ZNBprwNcdogET7aW9I0In7Wu5f2KqI6e5W/spJHCy4JVxzVMUvk6Myab0LnJ2iQ== dummy@gitlab.com') + create(:rsa_deploy_key_5120, public: true) + project_key = create(:deploy_key) + internal_key = create(:deploy_key) create(:deploy_keys_project, project: project, deploy_key: project_key) create(:deploy_keys_project, project: project2, deploy_key: internal_key) create(:deploy_keys_project, project: project3, deploy_key: project_key) diff --git a/spec/frontend/fixtures/jobs.rb b/spec/frontend/fixtures/jobs.rb index 3cc87432655..2e15eefdce6 100644 --- a/spec/frontend/fixtures/jobs.rb +++ b/spec/frontend/fixtures/jobs.rb @@ -2,40 +2,94 @@ require 'spec_helper' -RSpec.describe Projects::JobsController, '(JavaScript fixtures)', type: :controller do +RSpec.describe 'Jobs (JavaScript fixtures)' do + include ApiHelpers include JavaScriptFixturesHelpers + include GraphqlHelpers let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project, :repository, namespace: namespace, path: 'builds-project') } let(:user) { project.first_owner } let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.id) } - let!(:build_with_artifacts) { create(:ci_build, :success, :artifacts, :trace_artifact, pipeline: pipeline, stage: 'test', artifacts_expire_at: Time.now + 18.months) } - let!(:failed_build) { create(:ci_build, :failed, pipeline: pipeline, stage: 'build') } - let!(:pending_build) { create(:ci_build, :pending, pipeline: pipeline, stage: 'deploy') } - let!(:delayed_job) do - create(:ci_build, :scheduled, - pipeline: pipeline, - name: 'delayed job', - stage: 'test') + + after do + remove_repository(project) end - render_views + describe Projects::JobsController, type: :controller do + let!(:delayed) { create(:ci_build, :scheduled, pipeline: pipeline, name: 'delayed job') } - before do - sign_in(user) - end + before do + sign_in(user) + end - after do - remove_repository(project) + it 'jobs/delayed.json' do + get :show, params: { + namespace_id: project.namespace.to_param, + project_id: project, + id: delayed.to_param + }, format: :json + + expect(response).to be_successful + end end - it 'jobs/delayed.json' do - get :show, params: { - namespace_id: project.namespace.to_param, - project_id: project, - id: delayed_job.to_param - }, format: :json + describe GraphQL::Query, type: :request do + let(:artifact) { create(:ci_job_artifact, file_type: :archive, file_format: :zip) } + + let!(:build) { create(:ci_build, :success, name: 'build', pipeline: pipeline) } + let!(:cancelable) { create(:ci_build, :cancelable, name: 'cancelable', pipeline: pipeline) } + let!(:created_by_tag) { create(:ci_build, :success, name: 'created_by_tag', tag: true, pipeline: pipeline) } + let!(:pending) { create(:ci_build, :pending, name: 'pending', pipeline: pipeline) } + let!(:playable) { create(:ci_build, :playable, name: 'playable', pipeline: pipeline) } + let!(:retryable) { create(:ci_build, :retryable, name: 'retryable', pipeline: pipeline) } + let!(:scheduled) { create(:ci_build, :scheduled, name: 'scheduled', pipeline: pipeline) } + let!(:with_artifact) { create(:ci_build, :success, name: 'with_artifact', job_artifacts: [artifact], pipeline: pipeline) } + let!(:with_coverage) { create(:ci_build, :success, name: 'with_coverage', coverage: 40.0, pipeline: pipeline) } + + fixtures_path = 'graphql/jobs/' + get_jobs_query = 'get_jobs.query.graphql' + full_path = 'frontend-fixtures/builds-project' + + let_it_be(:query) do + get_graphql_query_as_string("jobs/components/table/graphql/queries/#{get_jobs_query}") + end + + it "#{fixtures_path}#{get_jobs_query}.json" do + post_graphql(query, current_user: user, variables: { + fullPath: full_path + }) + + expect_graphql_errors_to_be_empty + end + + it "#{fixtures_path}#{get_jobs_query}.as_guest.json" do + guest = create(:user) + project.add_guest(guest) + + post_graphql(query, current_user: guest, variables: { + fullPath: full_path + }) + + expect_graphql_errors_to_be_empty + end + + it "#{fixtures_path}#{get_jobs_query}.paginated.json" do + post_graphql(query, current_user: user, variables: { + fullPath: full_path, + first: 2 + }) + + expect_graphql_errors_to_be_empty + end + + it "#{fixtures_path}#{get_jobs_query}.empty.json" do + post_graphql(query, current_user: user, variables: { + fullPath: full_path, + first: 0 + }) - expect(response).to be_successful + expect_graphql_errors_to_be_empty + end end end diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb index a79982fa647..36281af0219 100644 --- a/spec/frontend/fixtures/runner.rb +++ b/spec/frontend/fixtures/runner.rb @@ -29,7 +29,7 @@ RSpec.describe 'Runner (JavaScript fixtures)' do before do allow(Gitlab::Ci::RunnerUpgradeCheck.instance) .to receive(:check_runner_upgrade_status) - .and_return(:not_available) + .and_return({ not_available: nil }) end describe do @@ -39,19 +39,19 @@ RSpec.describe 'Runner (JavaScript fixtures)' do end describe GraphQL::Query, type: :request do - admin_runners_query = 'list/admin_runners.query.graphql' + all_runners_query = 'list/all_runners.query.graphql' let_it_be(:query) do - get_graphql_query_as_string("#{query_path}#{admin_runners_query}") + get_graphql_query_as_string("#{query_path}#{all_runners_query}") end - it "#{fixtures_path}#{admin_runners_query}.json" do + it "#{fixtures_path}#{all_runners_query}.json" do post_graphql(query, current_user: admin, variables: {}) expect_graphql_errors_to_be_empty end - it "#{fixtures_path}#{admin_runners_query}.paginated.json" do + it "#{fixtures_path}#{all_runners_query}.paginated.json" do post_graphql(query, current_user: admin, variables: { first: 2 }) expect_graphql_errors_to_be_empty @@ -59,13 +59,13 @@ RSpec.describe 'Runner (JavaScript fixtures)' do end describe GraphQL::Query, type: :request do - admin_runners_count_query = 'list/admin_runners_count.query.graphql' + all_runners_count_query = 'list/all_runners_count.query.graphql' let_it_be(:query) do - get_graphql_query_as_string("#{query_path}#{admin_runners_count_query}") + get_graphql_query_as_string("#{query_path}#{all_runners_count_query}") end - it "#{fixtures_path}#{admin_runners_count_query}.json" do + it "#{fixtures_path}#{all_runners_count_query}.json" do post_graphql(query, current_user: admin, variables: {}) expect_graphql_errors_to_be_empty diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 552377e3381..072cf34d0ef 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -738,7 +738,7 @@ describe('GfmAutoComplete', () => { $textarea.trigger('focus').val(text).caret('pos', -1); $textarea.trigger('keyup'); - return new Promise(window.requestAnimationFrame); + jest.runOnlyPendingTimers(); }; const getDropdownItems = () => { @@ -747,10 +747,11 @@ describe('GfmAutoComplete', () => { return [].map.call(items, (item) => item.textContent.trim()); }; - const expectLabels = ({ input, output }) => - triggerDropdown(input).then(() => { - expect(getDropdownItems()).toEqual(output.map((label) => label.title)); - }); + const expectLabels = ({ input, output }) => { + triggerDropdown(input); + + expect(getDropdownItems()).toEqual(output.map((label) => label.title)); + }; describe('with no labels assigned', () => { beforeEach(() => { diff --git a/spec/frontend/gitlab_pages/new/pages/pages_pipeline_wizard_spec.js b/spec/frontend/gitlab_pages/new/pages/pages_pipeline_wizard_spec.js new file mode 100644 index 00000000000..685b5144a95 --- /dev/null +++ b/spec/frontend/gitlab_pages/new/pages/pages_pipeline_wizard_spec.js @@ -0,0 +1,102 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import PagesPipelineWizard, { i18n } from '~/gitlab_pages/components/pages_pipeline_wizard.vue'; +import PipelineWizard from '~/pipeline_wizard/pipeline_wizard.vue'; +import pagesTemplate from '~/pipeline_wizard/templates/pages.yml'; +import pagesMarkOnboardingComplete from '~/gitlab_pages/queries/mark_onboarding_complete.graphql'; +import { redirectTo } from '~/lib/utils/url_utility'; + +Vue.use(VueApollo); + +jest.mock('~/lib/utils/url_utility'); + +describe('PagesPipelineWizard', () => { + const markOnboardingCompleteMutationHandler = jest.fn(); + let wrapper; + const props = { + projectPath: '/user/repo', + defaultBranch: 'main', + redirectToWhenDone: './', + }; + + const findPipelineWizardWrapper = () => wrapper.findComponent(PipelineWizard); + const createMockApolloProvider = () => { + return createMockApollo([ + [ + pagesMarkOnboardingComplete, + markOnboardingCompleteMutationHandler.mockResolvedValue({ + data: { + pagesMarkOnboardingComplete: { + onboardingComplete: true, + errors: [], + }, + }, + }), + ], + ]); + }; + + const createComponent = () => { + wrapper = shallowMountExtended(PagesPipelineWizard, { + apolloProvider: createMockApolloProvider(), + propsData: props, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('shows the pipeline wizard', () => { + expect(findPipelineWizardWrapper().exists()).toBe(true); + }); + + it('passes the appropriate props', () => { + const pipelineWizardWrapperProps = findPipelineWizardWrapper().props(); + + expect(pipelineWizardWrapperProps.template).toBe(pagesTemplate); + expect(pipelineWizardWrapperProps.projectPath).toBe(props.projectPath); + expect(pipelineWizardWrapperProps.defaultBranch).toBe(props.defaultBranch); + }); + + describe('after the steps are complete', () => { + const mockDone = () => findPipelineWizardWrapper().vm.$emit('done'); + + it('shows a loading screen during the update', async () => { + mockDone(); + + await nextTick(); + + const loadingScreenWrapper = wrapper.findByTestId('onboarding-mutation-loading'); + expect(loadingScreenWrapper.exists()).toBe(true); + expect(loadingScreenWrapper.text()).toBe(i18n.loadingMessage); + }); + + it('calls pagesMarkOnboardingComplete mutation when done', async () => { + mockDone(); + + await waitForPromises(); + + expect(markOnboardingCompleteMutationHandler).toHaveBeenCalledWith({ + input: { + projectPath: props.projectPath, + }, + }); + }); + + it('navigates to the path defined in redirectToWhenDone when done', async () => { + mockDone(); + + await waitForPromises(); + + expect(redirectTo).toHaveBeenCalledWith(props.redirectToWhenDone); + }); + }); +}); diff --git a/spec/frontend/google_cloud/components/app_spec.js b/spec/frontend/google_cloud/components/app_spec.js deleted file mode 100644 index 0cafe6d3b9d..00000000000 --- a/spec/frontend/google_cloud/components/app_spec.js +++ /dev/null @@ -1,77 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { mapValues } from 'lodash'; -import App from '~/google_cloud/components/app.vue'; -import Home from '~/google_cloud/components/home.vue'; -import IncubationBanner from '~/google_cloud/components/incubation_banner.vue'; -import ServiceAccountsForm from '~/google_cloud/components/service_accounts_form.vue'; -import GcpError from '~/google_cloud/components/errors/gcp_error.vue'; -import NoGcpProjects from '~/google_cloud/components/errors/no_gcp_projects.vue'; - -const BASE_FEEDBACK_URL = - 'https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new'; -const SCREEN_COMPONENTS = { - Home, - ServiceAccountsForm, - GcpError, - NoGcpProjects, -}; -const SERVICE_ACCOUNTS_FORM_PROPS = { - gcpProjects: [1, 2, 3], - refs: [4, 5, 6], - cancelPath: '', -}; -const HOME_PROPS = { - serviceAccounts: [{}, {}], - gcpRegions: [{}, {}], - createServiceAccountUrl: '#url-create-service-account', - configureGcpRegionsUrl: '#url-configure-gcp-regions', - emptyIllustrationUrl: '#url-empty-illustration', - enableCloudRunUrl: '#url-enable-cloud-run', - enableCloudStorageUrl: '#enableCloudStorageUrl', - revokeOauthUrl: '#revokeOauthUrl', -}; - -describe('google_cloud App component', () => { - let wrapper; - - const findIncubationBanner = () => wrapper.findComponent(IncubationBanner); - - afterEach(() => { - wrapper.destroy(); - }); - - describe.each` - screen | extraProps | componentName - ${'gcp_error'} | ${{ error: 'mock_gcp_client_error' }} | ${'GcpError'} - ${'no_gcp_projects'} | ${{}} | ${'NoGcpProjects'} - ${'service_accounts_form'} | ${SERVICE_ACCOUNTS_FORM_PROPS} | ${'ServiceAccountsForm'} - ${'home'} | ${HOME_PROPS} | ${'Home'} - `('for screen=$screen', ({ screen, extraProps, componentName }) => { - const component = SCREEN_COMPONENTS[componentName]; - - beforeEach(() => { - wrapper = shallowMount(App, { propsData: { screen, ...extraProps } }); - }); - - it(`renders only ${componentName}`, () => { - const existences = mapValues(SCREEN_COMPONENTS, (x) => wrapper.findComponent(x).exists()); - - expect(existences).toEqual({ - ...mapValues(SCREEN_COMPONENTS, () => false), - [componentName]: true, - }); - }); - - it(`renders the ${componentName} with props`, () => { - expect(wrapper.findComponent(component).props()).toEqual(extraProps); - }); - - it('renders incubation banner', () => { - expect(findIncubationBanner().props()).toEqual({ - shareFeedbackUrl: `${BASE_FEEDBACK_URL}?issuable_template=general_feedback`, - reportBugUrl: `${BASE_FEEDBACK_URL}?issuable_template=report_bug`, - featureRequestUrl: `${BASE_FEEDBACK_URL}?issuable_template=feature_request`, - }); - }); - }); -}); diff --git a/spec/frontend/google_cloud/components/errors/gcp_error_spec.js b/spec/frontend/google_cloud/components/errors/gcp_error_spec.js deleted file mode 100644 index 4062a8b902a..00000000000 --- a/spec/frontend/google_cloud/components/errors/gcp_error_spec.js +++ /dev/null @@ -1,34 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlAlert } from '@gitlab/ui'; -import GcpError from '~/google_cloud/components/errors/gcp_error.vue'; - -describe('GcpError component', () => { - let wrapper; - - const findAlert = () => wrapper.findComponent(GlAlert); - const findBlockquote = () => wrapper.find('blockquote'); - - const propsData = { error: 'IAM and CloudResourceManager API disabled' }; - - beforeEach(() => { - wrapper = shallowMount(GcpError, { propsData }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('contains alert', () => { - expect(findAlert().exists()).toBe(true); - }); - - it('contains relevant text', () => { - const alertText = findAlert().text(); - expect(findAlert().props('title')).toBe(GcpError.i18n.title); - expect(alertText).toContain(GcpError.i18n.description); - }); - - it('contains error stacktrace', () => { - expect(findBlockquote().text()).toBe(propsData.error); - }); -}); diff --git a/spec/frontend/google_cloud/components/errors/no_gcp_projects_spec.js b/spec/frontend/google_cloud/components/errors/no_gcp_projects_spec.js deleted file mode 100644 index e1e20377880..00000000000 --- a/spec/frontend/google_cloud/components/errors/no_gcp_projects_spec.js +++ /dev/null @@ -1,33 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { GlAlert, GlButton } from '@gitlab/ui'; -import NoGcpProjects from '~/google_cloud/components/errors/no_gcp_projects.vue'; - -describe('NoGcpProjects component', () => { - let wrapper; - - const findAlert = () => wrapper.findComponent(GlAlert); - const findButton = () => wrapper.findComponent(GlButton); - - beforeEach(() => { - wrapper = mount(NoGcpProjects); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('contains alert', () => { - expect(findAlert().exists()).toBe(true); - }); - - it('contains relevant text', () => { - expect(findAlert().props('title')).toBe(NoGcpProjects.i18n.title); - expect(findAlert().text()).toContain(NoGcpProjects.i18n.description); - }); - - it('contains create gcp project button', () => { - const button = findButton(); - expect(button.text()).toBe(NoGcpProjects.i18n.createLabel); - expect(button.attributes('href')).toBe('https://console.cloud.google.com/projectcreate'); - }); -}); diff --git a/spec/frontend/google_cloud/components/google_cloud_menu_spec.js b/spec/frontend/google_cloud/components/google_cloud_menu_spec.js new file mode 100644 index 00000000000..4809ea37045 --- /dev/null +++ b/spec/frontend/google_cloud/components/google_cloud_menu_spec.js @@ -0,0 +1,40 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import GoogleCloudMenu from '~/google_cloud/components/google_cloud_menu.vue'; + +describe('google_cloud/components/google_cloud_menu', () => { + let wrapper; + + const props = { + active: 'configuration', + configurationUrl: 'configuration-url', + deploymentsUrl: 'deployments-url', + databasesUrl: 'databases-url', + }; + + beforeEach(() => { + wrapper = mountExtended(GoogleCloudMenu, { propsData: props }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains active configuration link', () => { + const link = wrapper.findByTestId('configurationLink'); + expect(link.text()).toBe(GoogleCloudMenu.i18n.configuration.title); + expect(link.attributes('href')).toBe(props.configurationUrl); + expect(link.element.classList.contains('gl-tab-nav-item-active')).toBe(true); + }); + + it('contains deployments link', () => { + const link = wrapper.findByTestId('deploymentsLink'); + expect(link.text()).toBe(GoogleCloudMenu.i18n.deployments.title); + expect(link.attributes('href')).toBe(props.deploymentsUrl); + }); + + it('contains databases link', () => { + const link = wrapper.findByTestId('databasesLink'); + expect(link.text()).toBe(GoogleCloudMenu.i18n.databases.title); + expect(link.attributes('href')).toBe(props.databasesUrl); + }); +}); diff --git a/spec/frontend/google_cloud/components/home_spec.js b/spec/frontend/google_cloud/components/home_spec.js deleted file mode 100644 index 42e3d72577d..00000000000 --- a/spec/frontend/google_cloud/components/home_spec.js +++ /dev/null @@ -1,66 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlTab, GlTabs } from '@gitlab/ui'; -import Home from '~/google_cloud/components/home.vue'; -import ServiceAccountsList from '~/google_cloud/components/service_accounts_list.vue'; - -describe('google_cloud Home component', () => { - let wrapper; - - const findTabs = () => wrapper.findComponent(GlTabs); - const findTabItems = () => findTabs().findAllComponents(GlTab); - const findTabItemsModel = () => - findTabs() - .findAllComponents(GlTab) - .wrappers.map((x) => ({ - title: x.attributes('title'), - disabled: x.attributes('disabled'), - })); - - const TEST_HOME_PROPS = { - serviceAccounts: [{}, {}], - gcpRegions: [{}, {}], - createServiceAccountUrl: '#url-create-service-account', - configureGcpRegionsUrl: '#url-configure-gcp-regions', - emptyIllustrationUrl: '#url-empty-illustration', - enableCloudRunUrl: '#url-enable-cloud-run', - enableCloudStorageUrl: '#enableCloudStorageUrl', - revokeOauthUrl: '#revokeOauthUrl', - }; - - beforeEach(() => { - const propsData = { - screen: 'home', - ...TEST_HOME_PROPS, - }; - wrapper = shallowMount(Home, { propsData }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('google_cloud App tabs', () => { - it('should contain tabs', () => { - expect(findTabs().exists()).toBe(true); - }); - - it('should contain three tab items', () => { - expect(findTabItemsModel()).toEqual([ - { title: 'Configuration', disabled: undefined }, - { title: 'Deployments', disabled: undefined }, - { title: 'Services', disabled: '' }, - ]); - }); - - describe('configuration tab', () => { - it('should contain service accounts component', () => { - const serviceAccounts = findTabItems().at(0).findComponent(ServiceAccountsList); - expect(serviceAccounts.props()).toEqual({ - list: TEST_HOME_PROPS.serviceAccounts, - createUrl: TEST_HOME_PROPS.createServiceAccountUrl, - emptyIllustrationUrl: TEST_HOME_PROPS.emptyIllustrationUrl, - }); - }); - }); - }); -}); diff --git a/spec/frontend/google_cloud/components/incubation_banner_spec.js b/spec/frontend/google_cloud/components/incubation_banner_spec.js index 89517be4ef1..09a4d92dca2 100644 --- a/spec/frontend/google_cloud/components/incubation_banner_spec.js +++ b/spec/frontend/google_cloud/components/incubation_banner_spec.js @@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'; import { GlAlert, GlLink } from '@gitlab/ui'; import IncubationBanner from '~/google_cloud/components/incubation_banner.vue'; -describe('IncubationBanner component', () => { +describe('google_cloud/components/incubation_banner', () => { let wrapper; const findAlert = () => wrapper.findComponent(GlAlert); @@ -12,12 +12,7 @@ describe('IncubationBanner component', () => { const findShareFeedbackLink = () => findLinks().at(2); beforeEach(() => { - const propsData = { - shareFeedbackUrl: 'url_general_feedback', - reportBugUrl: 'url_report_bug', - featureRequestUrl: 'url_feature_request', - }; - wrapper = mount(IncubationBanner, { propsData }); + wrapper = mount(IncubationBanner); }); afterEach(() => { @@ -41,20 +36,26 @@ describe('IncubationBanner component', () => { it('contains feature request link', () => { const link = findFeatureRequestLink(); + const expected = + 'https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new?issuable_template=feature_request'; expect(link.text()).toBe('request a feature'); - expect(link.attributes('href')).toBe('url_feature_request'); + expect(link.attributes('href')).toBe(expected); }); it('contains report bug link', () => { const link = findReportBugLink(); + const expected = + 'https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new?issuable_template=report_bug'; expect(link.text()).toBe('report a bug'); - expect(link.attributes('href')).toBe('url_report_bug'); + expect(link.attributes('href')).toBe(expected); }); it('contains share feedback link', () => { const link = findShareFeedbackLink(); + const expected = + 'https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new?issuable_template=general_feedback'; expect(link.text()).toBe('share feedback'); - expect(link.attributes('href')).toBe('url_general_feedback'); + expect(link.attributes('href')).toBe(expected); }); }); }); diff --git a/spec/frontend/google_cloud/components/revoke_oauth_spec.js b/spec/frontend/google_cloud/components/revoke_oauth_spec.js index 87580dbf6de..faaec07fc35 100644 --- a/spec/frontend/google_cloud/components/revoke_oauth_spec.js +++ b/spec/frontend/google_cloud/components/revoke_oauth_spec.js @@ -5,7 +5,7 @@ import RevokeOauth, { GOOGLE_CLOUD_REVOKE_DESCRIPTION, } from '~/google_cloud/components/revoke_oauth.vue'; -describe('RevokeOauth component', () => { +describe('google_cloud/components/revoke_oauth', () => { let wrapper; const findTitle = () => wrapper.find('h2'); diff --git a/spec/frontend/google_cloud/configuration/panel_spec.js b/spec/frontend/google_cloud/configuration/panel_spec.js new file mode 100644 index 00000000000..79eb4cb4918 --- /dev/null +++ b/spec/frontend/google_cloud/configuration/panel_spec.js @@ -0,0 +1,65 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Panel from '~/google_cloud/configuration/panel.vue'; +import IncubationBanner from '~/google_cloud/components/incubation_banner.vue'; +import GoogleCloudMenu from '~/google_cloud/components/google_cloud_menu.vue'; +import ServiceAccountsList from '~/google_cloud/service_accounts/list.vue'; +import GcpRegionsList from '~/google_cloud/gcp_regions/list.vue'; +import RevokeOauth from '~/google_cloud/components/revoke_oauth.vue'; + +describe('google_cloud/configuration/panel', () => { + let wrapper; + + const props = { + configurationUrl: 'configuration-url', + deploymentsUrl: 'deployments-url', + databasesUrl: 'databases-url', + serviceAccounts: [], + createServiceAccountUrl: 'create-service-account-url', + emptyIllustrationUrl: 'empty-illustration-url', + gcpRegions: [], + configureGcpRegionsUrl: 'configure-gcp-regions-url', + revokeOauthUrl: 'revoke-oauth-url', + }; + + beforeEach(() => { + wrapper = shallowMountExtended(Panel, { propsData: props }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains incubation banner', () => { + const target = wrapper.findComponent(IncubationBanner); + expect(target.exists()).toBe(true); + }); + + it('contains google cloud menu with `configuration` active', () => { + const target = wrapper.findComponent(GoogleCloudMenu); + expect(target.exists()).toBe(true); + expect(target.props('active')).toBe('configuration'); + expect(target.props('configurationUrl')).toBe(props.configurationUrl); + expect(target.props('deploymentsUrl')).toBe(props.deploymentsUrl); + expect(target.props('databasesUrl')).toBe(props.databasesUrl); + }); + + it('contains service accounts list', () => { + const target = wrapper.findComponent(ServiceAccountsList); + expect(target.exists()).toBe(true); + expect(target.props('list')).toBe(props.serviceAccounts); + expect(target.props('createUrl')).toBe(props.createServiceAccountUrl); + expect(target.props('emptyIllustrationUrl')).toBe(props.emptyIllustrationUrl); + }); + + it('contains gcp regions list', () => { + const target = wrapper.findComponent(GcpRegionsList); + expect(target.props('list')).toBe(props.gcpRegions); + expect(target.props('createUrl')).toBe(props.configureGcpRegionsUrl); + expect(target.props('emptyIllustrationUrl')).toBe(props.emptyIllustrationUrl); + }); + + it('contains revoke oauth', () => { + const target = wrapper.findComponent(RevokeOauth); + expect(target.props('url')).toBe(props.revokeOauthUrl); + }); +}); diff --git a/spec/frontend/google_cloud/databases/cloudsql/create_instance_form_spec.js b/spec/frontend/google_cloud/databases/cloudsql/create_instance_form_spec.js new file mode 100644 index 00000000000..48e4b0ca1ad --- /dev/null +++ b/spec/frontend/google_cloud/databases/cloudsql/create_instance_form_spec.js @@ -0,0 +1,103 @@ +import { GlFormCheckbox } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import InstanceForm from '~/google_cloud/databases/cloudsql/create_instance_form.vue'; + +describe('google_cloud/databases/cloudsql/create_instance_form', () => { + let wrapper; + + const findByTestId = (id) => wrapper.findByTestId(id); + const findCancelButton = () => findByTestId('cancel-button'); + const findCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const findHeader = () => wrapper.find('header'); + const findSubmitButton = () => findByTestId('submit-button'); + + const propsData = { + gcpProjects: [], + refs: [], + cancelPath: '#cancel-url', + formTitle: 'mock form title', + formDescription: 'mock form description', + databaseVersions: [], + tiers: [], + }; + + beforeEach(() => { + wrapper = shallowMountExtended(InstanceForm, { propsData, stubs: { GlFormCheckbox } }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains header', () => { + expect(findHeader().exists()).toBe(true); + }); + + it('contains GCP project form group', () => { + const formGroup = findByTestId('form_group_gcp_project'); + expect(formGroup.exists()).toBe(true); + expect(formGroup.attributes('label')).toBe(InstanceForm.i18n.gcpProjectLabel); + expect(formGroup.attributes('description')).toBe(InstanceForm.i18n.gcpProjectDescription); + }); + + it('contains GCP project dropdown', () => { + const select = findByTestId('select_gcp_project'); + expect(select.exists()).toBe(true); + }); + + it('contains Environments form group', () => { + const formGroup = findByTestId('form_group_environments'); + expect(formGroup.exists()).toBe(true); + expect(formGroup.attributes('label')).toBe(InstanceForm.i18n.refsLabel); + expect(formGroup.attributes('description')).toBe(InstanceForm.i18n.refsDescription); + }); + + it('contains Environments dropdown', () => { + const select = findByTestId('select_environments'); + expect(select.exists()).toBe(true); + }); + + it('contains Tier form group', () => { + const formGroup = findByTestId('form_group_tier'); + expect(formGroup.exists()).toBe(true); + expect(formGroup.attributes('label')).toBe(InstanceForm.i18n.tierLabel); + expect(formGroup.attributes('description')).toBe(InstanceForm.i18n.tierDescription); + }); + + it('contains Tier dropdown', () => { + const select = findByTestId('select_tier'); + expect(select.exists()).toBe(true); + }); + + it('contains Database Version form group', () => { + const formGroup = findByTestId('form_group_database_version'); + expect(formGroup.exists()).toBe(true); + expect(formGroup.attributes('label')).toBe(InstanceForm.i18n.databaseVersionLabel); + }); + + it('contains Database Version dropdown', () => { + const select = findByTestId('select_database_version'); + expect(select.exists()).toBe(true); + }); + + it('contains Submit button', () => { + expect(findSubmitButton().exists()).toBe(true); + expect(findSubmitButton().text()).toBe(InstanceForm.i18n.submitLabel); + }); + + it('contains Cancel button', () => { + expect(findCancelButton().exists()).toBe(true); + expect(findCancelButton().text()).toBe(InstanceForm.i18n.cancelLabel); + expect(findCancelButton().attributes('href')).toBe('#cancel-url'); + }); + + it('contains Confirmation checkbox', () => { + const checkbox = findCheckbox(); + expect(checkbox.text()).toBe(InstanceForm.i18n.checkboxLabel); + }); + + it('checkbox must be required', () => { + const checkbox = findCheckbox(); + expect(checkbox.attributes('required')).toBe('true'); + }); +}); diff --git a/spec/frontend/google_cloud/databases/cloudsql/instance_table_spec.js b/spec/frontend/google_cloud/databases/cloudsql/instance_table_spec.js new file mode 100644 index 00000000000..a5736d0a524 --- /dev/null +++ b/spec/frontend/google_cloud/databases/cloudsql/instance_table_spec.js @@ -0,0 +1,65 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlEmptyState, GlTable } from '@gitlab/ui'; +import InstanceTable from '~/google_cloud/databases/cloudsql/instance_table.vue'; + +describe('google_cloud/databases/cloudsql/instance_table', () => { + let wrapper; + + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findTable = () => wrapper.findComponent(GlTable); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when there are no instances', () => { + beforeEach(() => { + const propsData = { + cloudsqlInstances: [], + emptyIllustrationUrl: '#empty-illustration-url', + }; + wrapper = shallowMount(InstanceTable, { propsData }); + }); + + it('should depict empty state', () => { + const emptyState = findEmptyState(); + expect(emptyState.exists()).toBe(true); + expect(emptyState.attributes('title')).toBe(InstanceTable.i18n.noInstancesTitle); + expect(emptyState.attributes('description')).toBe(InstanceTable.i18n.noInstancesDescription); + }); + }); + + describe('when there are three instances', () => { + beforeEach(() => { + const propsData = { + cloudsqlInstances: [ + { + ref: '*', + gcp_project: 'test-gcp-project', + instance_name: 'postgres-14-instance', + version: 'POSTGRES_14', + }, + { + ref: 'production', + gcp_project: 'prod-gcp-project', + instance_name: 'postgres-14-instance', + version: 'POSTGRES_14', + }, + { + ref: 'staging', + gcp_project: 'test-gcp-project', + instance_name: 'postgres-14-instance', + version: 'POSTGRES_14', + }, + ], + emptyIllustrationUrl: '#empty-illustration-url', + }; + wrapper = shallowMount(InstanceTable, { propsData }); + }); + + it('should contain a table', () => { + const table = findTable(); + expect(table.exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/google_cloud/databases/panel_spec.js b/spec/frontend/google_cloud/databases/panel_spec.js new file mode 100644 index 00000000000..490c0136651 --- /dev/null +++ b/spec/frontend/google_cloud/databases/panel_spec.js @@ -0,0 +1,36 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Panel from '~/google_cloud/databases/panel.vue'; +import IncubationBanner from '~/google_cloud/components/incubation_banner.vue'; +import GoogleCloudMenu from '~/google_cloud/components/google_cloud_menu.vue'; + +describe('google_cloud/databases/panel', () => { + let wrapper; + + const props = { + configurationUrl: 'configuration-url', + deploymentsUrl: 'deployments-url', + databasesUrl: 'databases-url', + }; + + beforeEach(() => { + wrapper = shallowMountExtended(Panel, { propsData: props }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains incubation banner', () => { + const target = wrapper.findComponent(IncubationBanner); + expect(target.exists()).toBe(true); + }); + + it('contains google cloud menu with `databases` active', () => { + const target = wrapper.findComponent(GoogleCloudMenu); + expect(target.exists()).toBe(true); + expect(target.props('active')).toBe('databases'); + expect(target.props('configurationUrl')).toBe(props.configurationUrl); + expect(target.props('deploymentsUrl')).toBe(props.deploymentsUrl); + expect(target.props('databasesUrl')).toBe(props.databasesUrl); + }); +}); diff --git a/spec/frontend/google_cloud/databases/service_table_spec.js b/spec/frontend/google_cloud/databases/service_table_spec.js new file mode 100644 index 00000000000..4a622e544e1 --- /dev/null +++ b/spec/frontend/google_cloud/databases/service_table_spec.js @@ -0,0 +1,44 @@ +import { GlTable } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import ServiceTable from '~/google_cloud/databases/service_table.vue'; + +describe('google_cloud/databases/service_table', () => { + let wrapper; + + const findTable = () => wrapper.findComponent(GlTable); + + beforeEach(() => { + const propsData = { + cloudsqlPostgresUrl: '#url-cloudsql-postgres', + cloudsqlMysqlUrl: '#url-cloudsql-mysql', + cloudsqlSqlserverUrl: '#url-cloudsql-sqlserver', + alloydbPostgresUrl: '#url-alloydb-postgres', + memorystoreRedisUrl: '#url-memorystore-redis', + firestoreUrl: '#url-firestore', + }; + wrapper = mountExtended(ServiceTable, { propsData }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should contain a table', () => { + expect(findTable().exists()).toBe(true); + }); + + it.each` + name | testId | url + ${'cloudsql-postgres'} | ${'button-cloudsql-postgres'} | ${'#url-cloudsql-postgres'} + ${'cloudsql-mysql'} | ${'button-cloudsql-mysql'} | ${'#url-cloudsql-mysql'} + ${'cloudsql-sqlserver'} | ${'button-cloudsql-sqlserver'} | ${'#url-cloudsql-sqlserver'} + ${'alloydb-postgres'} | ${'button-alloydb-postgres'} | ${'#url-alloydb-postgres'} + ${'memorystore-redis'} | ${'button-memorystore-redis'} | ${'#url-memorystore-redis'} + ${'firestore'} | ${'button-firestore'} | ${'#url-firestore'} + `('renders $name button with correct url', ({ testId, url }) => { + const button = wrapper.findByTestId(testId); + + expect(button.exists()).toBe(true); + expect(button.attributes('href')).toBe(url); + }); +}); diff --git a/spec/frontend/google_cloud/deployments/panel_spec.js b/spec/frontend/google_cloud/deployments/panel_spec.js new file mode 100644 index 00000000000..729db1707a7 --- /dev/null +++ b/spec/frontend/google_cloud/deployments/panel_spec.js @@ -0,0 +1,46 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Panel from '~/google_cloud/deployments/panel.vue'; +import IncubationBanner from '~/google_cloud/components/incubation_banner.vue'; +import GoogleCloudMenu from '~/google_cloud/components/google_cloud_menu.vue'; +import ServiceTable from '~/google_cloud/deployments/service_table.vue'; + +describe('google_cloud/deployments/panel', () => { + let wrapper; + + const props = { + configurationUrl: 'configuration-url', + deploymentsUrl: 'deployments-url', + databasesUrl: 'databases-url', + enableCloudRunUrl: 'cloud-run-url', + enableCloudStorageUrl: 'cloud-storage-url', + }; + + beforeEach(() => { + wrapper = shallowMountExtended(Panel, { propsData: props }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains incubation banner', () => { + const target = wrapper.findComponent(IncubationBanner); + expect(target.exists()).toBe(true); + }); + + it('contains google cloud menu with `deployments` active', () => { + const target = wrapper.findComponent(GoogleCloudMenu); + expect(target.exists()).toBe(true); + expect(target.props('active')).toBe('deployments'); + expect(target.props('configurationUrl')).toBe(props.configurationUrl); + expect(target.props('deploymentsUrl')).toBe(props.deploymentsUrl); + expect(target.props('databasesUrl')).toBe(props.databasesUrl); + }); + + it('contains service-table', () => { + const target = wrapper.findComponent(ServiceTable); + expect(target.exists()).toBe(true); + expect(target.props('cloudRunUrl')).toBe(props.enableCloudRunUrl); + expect(target.props('cloudStorageUrl')).toBe(props.enableCloudStorageUrl); + }); +}); diff --git a/spec/frontend/google_cloud/components/deployments_service_table_spec.js b/spec/frontend/google_cloud/deployments/service_table_spec.js index 882376547c4..8faad64e313 100644 --- a/spec/frontend/google_cloud/components/deployments_service_table_spec.js +++ b/spec/frontend/google_cloud/deployments/service_table_spec.js @@ -1,8 +1,8 @@ import { mount } from '@vue/test-utils'; import { GlButton, GlTable } from '@gitlab/ui'; -import DeploymentsServiceTable from '~/google_cloud/components/deployments_service_table.vue'; +import DeploymentsServiceTable from '~/google_cloud/deployments/service_table.vue'; -describe('google_cloud DeploymentsServiceTable component', () => { +describe('google_cloud/deployments/service_table', () => { let wrapper; const findTable = () => wrapper.findComponent(GlTable); diff --git a/spec/frontend/google_cloud/components/gcp_regions_form_spec.js b/spec/frontend/google_cloud/gcp_regions/form_spec.js index a8b7593e7c8..1030e9c8a18 100644 --- a/spec/frontend/google_cloud/components/gcp_regions_form_spec.js +++ b/spec/frontend/google_cloud/gcp_regions/form_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui'; -import GcpRegionsForm from '~/google_cloud/components/gcp_regions_form.vue'; +import GcpRegionsForm from '~/google_cloud/gcp_regions/form.vue'; -describe('GcpRegionsForm component', () => { +describe('google_cloud/gcp_regions/form', () => { let wrapper; const findHeader = () => wrapper.find('header'); diff --git a/spec/frontend/google_cloud/components/gcp_regions_list_spec.js b/spec/frontend/google_cloud/gcp_regions/list_spec.js index ab0c17451e8..6d8c389e5a1 100644 --- a/spec/frontend/google_cloud/components/gcp_regions_list_spec.js +++ b/spec/frontend/google_cloud/gcp_regions/list_spec.js @@ -1,8 +1,8 @@ import { mount } from '@vue/test-utils'; import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui'; -import GcpRegionsList from '~/google_cloud/components/gcp_regions_list.vue'; +import GcpRegionsList from '~/google_cloud/gcp_regions/list.vue'; -describe('GcpRegions component', () => { +describe('google_cloud/gcp_regions/list', () => { describe('when the project does not have any configured regions', () => { let wrapper; diff --git a/spec/frontend/google_cloud/components/service_accounts_form_spec.js b/spec/frontend/google_cloud/service_accounts/form_spec.js index 38602d4e8cc..8be481774fa 100644 --- a/spec/frontend/google_cloud/components/service_accounts_form_spec.js +++ b/spec/frontend/google_cloud/service_accounts/form_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; import { GlButton, GlFormGroup, GlFormSelect, GlFormCheckbox } from '@gitlab/ui'; -import ServiceAccountsForm from '~/google_cloud/components/service_accounts_form.vue'; +import ServiceAccountsForm from '~/google_cloud/service_accounts/form.vue'; -describe('ServiceAccountsForm component', () => { +describe('google_cloud/service_accounts/form', () => { let wrapper; const findHeader = () => wrapper.find('header'); diff --git a/spec/frontend/google_cloud/components/service_accounts_list_spec.js b/spec/frontend/google_cloud/service_accounts/list_spec.js index f7051c8a53d..7a76a893757 100644 --- a/spec/frontend/google_cloud/components/service_accounts_list_spec.js +++ b/spec/frontend/google_cloud/service_accounts/list_spec.js @@ -1,8 +1,8 @@ import { mount } from '@vue/test-utils'; import { GlAlert, GlButton, GlEmptyState, GlTable } from '@gitlab/ui'; -import ServiceAccountsList from '~/google_cloud/components/service_accounts_list.vue'; +import ServiceAccountsList from '~/google_cloud/service_accounts/list.vue'; -describe('ServiceAccounts component', () => { +describe('google_cloud/service_accounts/list', () => { describe('when the project does not have any service accounts', () => { let wrapper; diff --git a/spec/frontend/google_tag_manager/index_spec.js b/spec/frontend/google_tag_manager/index_spec.js index 50811f43fc3..6a7eb1fd9f1 100644 --- a/spec/frontend/google_tag_manager/index_spec.js +++ b/spec/frontend/google_tag_manager/index_spec.js @@ -10,6 +10,7 @@ import { trackSaasTrialGroup, trackSaasTrialProject, trackSaasTrialGetStarted, + trackTrialAcceptTerms, trackCheckout, trackTransaction, trackAddToCartUsageTab, @@ -255,6 +256,16 @@ describe('~/google_tag_manager/index', () => { expect(logError).not.toHaveBeenCalled(); }); + it('when trackTrialAcceptTerms is invoked', () => { + expect(spy).not.toHaveBeenCalled(); + + trackTrialAcceptTerms(); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith({ event: 'saasTrialAcceptTerms' }); + expect(logError).not.toHaveBeenCalled(); + }); + describe('when trackCheckout is invoked', () => { it('with selectedPlan: 2c92a00d76f0d5060176f2fb0a5029ff', () => { expect(spy).not.toHaveBeenCalled(); diff --git a/spec/frontend/groups/components/group_folder_spec.js b/spec/frontend/groups/components/group_folder_spec.js index 98b7c2dd6c6..f223333360d 100644 --- a/spec/frontend/groups/components/group_folder_spec.js +++ b/spec/frontend/groups/components/group_folder_spec.js @@ -1,65 +1,50 @@ -import Vue, { nextTick } from 'vue'; - -import groupFolderComponent from '~/groups/components/group_folder.vue'; -import groupItemComponent from '~/groups/components/group_item.vue'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import GroupFolder from '~/groups/components/group_folder.vue'; +import GroupItem from '~/groups/components/group_item.vue'; +import { MAX_CHILDREN_COUNT } from '~/groups/constants'; import { mockGroups, mockParentGroupItem } from '../mock_data'; -const createComponent = (groups = mockGroups, parentGroup = mockParentGroupItem) => { - const Component = Vue.extend(groupFolderComponent); - - return new Component({ - propsData: { - groups, - parentGroup, - }, - }); -}; +describe('GroupFolder component', () => { + let wrapper; -describe('GroupFolderComponent', () => { - let vm; + Vue.component('GroupItem', GroupItem); - beforeEach(async () => { - Vue.component('GroupItem', groupItemComponent); + const findLink = () => wrapper.find('a'); - vm = createComponent(); - vm.$mount(); - - await nextTick(); - }); + const createComponent = ({ groups = mockGroups, parentGroup = mockParentGroupItem } = {}) => + shallowMount(GroupFolder, { + propsData: { + groups, + parentGroup, + }, + }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); - describe('computed', () => { - describe('hasMoreChildren', () => { - it('should return false when childrenCount of group is less than MAX_CHILDREN_COUNT', () => { - expect(vm.hasMoreChildren).toBeFalsy(); - }); - }); + it('does not render more children stats link when children count of group is under limit', () => { + wrapper = createComponent(); - describe('moreChildrenStats', () => { - it('should return message with count of excess children over MAX_CHILDREN_COUNT limit', () => { - expect(vm.moreChildrenStats).toBe('3 more items'); - }); - }); + expect(findLink().exists()).toBe(false); }); - describe('template', () => { - it('should render component template correctly', () => { - expect(vm.$el.classList.contains('group-list-tree')).toBeTruthy(); - expect(vm.$el.querySelectorAll('li.group-row').length).toBe(7); + it('renders text of count of excess children when children count of group is over limit', () => { + const childrenCount = MAX_CHILDREN_COUNT + 1; + wrapper = createComponent({ + parentGroup: { + ...mockParentGroupItem, + childrenCount, + }, }); - it('should render more children link when groups list has children over MAX_CHILDREN_COUNT limit', () => { - const parentGroup = { ...mockParentGroupItem }; - parentGroup.childrenCount = 21; + expect(findLink().text()).toBe(`${childrenCount} more items`); + }); - const newVm = createComponent(mockGroups, parentGroup); - newVm.$mount(); + it('renders group items', () => { + wrapper = createComponent(); - expect(newVm.$el.querySelector('li.group-row a.has-more-items')).toBeDefined(); - newVm.$destroy(); - }); + expect(wrapper.findAllComponents(GroupItem)).toHaveLength(7); }); }); diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js index 8ea7e54aef4..0bc80df6535 100644 --- a/spec/frontend/groups/components/group_item_spec.js +++ b/spec/frontend/groups/components/group_item_spec.js @@ -1,4 +1,4 @@ -import { mount } from '@vue/test-utils'; +import { GlPopover } from '@gitlab/ui'; import waitForPromises from 'helpers/wait_for_promises'; import GroupFolder from '~/groups/components/group_folder.vue'; import GroupItem from '~/groups/components/group_item.vue'; @@ -6,14 +6,25 @@ import ItemActions from '~/groups/components/item_actions.vue'; import eventHub from '~/groups/event_hub'; import { getGroupItemMicrodata } from '~/groups/store/utils'; import * as urlUtilities from '~/lib/utils/url_utility'; +import { + ITEM_TYPE, + VISIBILITY_INTERNAL, + VISIBILITY_PRIVATE, + VISIBILITY_PUBLIC, +} from '~/groups/constants'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { mockParentGroupItem, mockChildren } from '../mock_data'; const createComponent = ( propsData = { group: mockParentGroupItem, parentGroup: mockChildren[0] }, + provide = { + currentGroupVisibility: VISIBILITY_PRIVATE, + }, ) => { - return mount(GroupItem, { + return mountExtended(GroupItem, { propsData, components: { GroupFolder }, + provide, }); }; @@ -276,4 +287,90 @@ describe('GroupItemComponent', () => { }); }); }); + + describe('visibility warning popover', () => { + const findPopover = () => wrapper.findComponent(GlPopover); + + const itDoesNotRenderVisibilityWarningPopover = () => { + it('does not render visibility warning popover', () => { + expect(findPopover().exists()).toBe(false); + }); + }; + + describe('when showing groups', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + itDoesNotRenderVisibilityWarningPopover(); + }); + + describe('when `action` prop is not `shared`', () => { + beforeEach(() => { + wrapper = createComponent({ + group: mockParentGroupItem, + parentGroup: mockChildren[0], + action: 'subgroups_and_projects', + }); + }); + + itDoesNotRenderVisibilityWarningPopover(); + }); + + describe('when showing projects', () => { + describe.each` + itemVisibility | currentGroupVisibility | isPopoverShown + ${VISIBILITY_PRIVATE} | ${VISIBILITY_PUBLIC} | ${false} + ${VISIBILITY_INTERNAL} | ${VISIBILITY_PUBLIC} | ${false} + ${VISIBILITY_PUBLIC} | ${VISIBILITY_PUBLIC} | ${false} + ${VISIBILITY_PRIVATE} | ${VISIBILITY_PRIVATE} | ${false} + ${VISIBILITY_INTERNAL} | ${VISIBILITY_PRIVATE} | ${true} + ${VISIBILITY_PUBLIC} | ${VISIBILITY_PRIVATE} | ${true} + `( + 'when item visibility is $itemVisibility and parent group visibility is $currentGroupVisibility', + ({ itemVisibility, currentGroupVisibility, isPopoverShown }) => { + beforeEach(() => { + wrapper = createComponent( + { + group: { + ...mockParentGroupItem, + visibility: itemVisibility, + type: ITEM_TYPE.PROJECT, + }, + parentGroup: mockChildren[0], + action: 'shared', + }, + { + currentGroupVisibility, + }, + ); + }); + + if (isPopoverShown) { + it('renders visibility warning popover', () => { + expect(findPopover().exists()).toBe(true); + }); + } else { + itDoesNotRenderVisibilityWarningPopover(); + } + }, + ); + }); + + it('sets up popover `target` prop correctly', () => { + wrapper = createComponent({ + group: { + ...mockParentGroupItem, + visibility: VISIBILITY_PUBLIC, + type: ITEM_TYPE.PROJECT, + }, + parentGroup: mockChildren[0], + action: 'shared', + }); + + expect(findPopover().props('target')()).toEqual( + wrapper.findByRole('button', { name: GroupItem.i18n.popoverTitle }).element, + ); + }); + }); }); diff --git a/spec/frontend/groups/components/group_name_and_path_spec.js b/spec/frontend/groups/components/group_name_and_path_spec.js index eaa0801ab50..9c9bdead6fa 100644 --- a/spec/frontend/groups/components/group_name_and_path_spec.js +++ b/spec/frontend/groups/components/group_name_and_path_spec.js @@ -1,3 +1,4 @@ +import { nextTick } from 'vue'; import { merge } from 'lodash'; import { GlAlert } from '@gitlab/ui'; import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -50,17 +51,17 @@ describe('GroupNameAndPath', () => { const findAlert = () => extendedWrapper(wrapper.findComponent(GlAlert)); const apiMockAvailablePath = () => { - getGroupPathAvailability.mockResolvedValue({ + getGroupPathAvailability.mockResolvedValueOnce({ data: { exists: false, suggests: [] }, }); }; const apiMockUnavailablePath = (suggests = [mockGroupUrlSuggested]) => { - getGroupPathAvailability.mockResolvedValue({ + getGroupPathAvailability.mockResolvedValueOnce({ data: { exists: true, suggests }, }); }; const apiMockLoading = () => { - getGroupPathAvailability.mockImplementation(() => new Promise(() => {})); + getGroupPathAvailability.mockImplementationOnce(() => new Promise(() => {})); }; const expectLoadingMessageExists = () => { @@ -169,7 +170,7 @@ describe('GroupNameAndPath', () => { describe('when API call fails', () => { it('calls `createAlert`', async () => { - getGroupPathAvailability.mockRejectedValue({}); + getGroupPathAvailability.mockRejectedValueOnce({}); createComponent(); @@ -184,14 +185,20 @@ describe('GroupNameAndPath', () => { describe('when multiple API calls are in-flight', () => { it('aborts the first API call and resolves second API call', async () => { - apiMockLoading(); + getGroupPathAvailability.mockRejectedValueOnce({ __CANCEL__: true }); apiMockUnavailablePath(); + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); createComponent(); await findGroupNameField().setValue('Foo'); await findGroupNameField().setValue(mockGroupName); + + // Wait for re-render to ensure loading message is still there + await nextTick(); + expectLoadingMessageExists(); + await waitForPromises(); expect(createAlert).not.toHaveBeenCalled(); diff --git a/spec/frontend/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js index 590b4fb3d57..48a2319cf96 100644 --- a/spec/frontend/groups/components/groups_spec.js +++ b/spec/frontend/groups/components/groups_spec.js @@ -1,45 +1,55 @@ -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import groupFolderComponent from '~/groups/components/group_folder.vue'; -import groupItemComponent from '~/groups/components/group_item.vue'; -import groupsComponent from '~/groups/components/groups.vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import GroupFolderComponent from '~/groups/components/group_folder.vue'; +import GroupItemComponent from '~/groups/components/group_item.vue'; +import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; +import GroupsComponent from '~/groups/components/groups.vue'; import eventHub from '~/groups/event_hub'; +import { VISIBILITY_PRIVATE } from '~/groups/constants'; import { mockGroups, mockPageInfo } from '../mock_data'; -const createComponent = (searchEmpty = false) => { - const Component = Vue.extend(groupsComponent); +describe('GroupsComponent', () => { + let wrapper; - return mountComponent(Component, { + const defaultPropsData = { groups: mockGroups, pageInfo: mockPageInfo, searchEmptyMessage: 'No matching results', - searchEmpty, - }); -}; + searchEmpty: false, + }; -describe('GroupsComponent', () => { - let vm; - - beforeEach(async () => { - Vue.component('GroupFolder', groupFolderComponent); - Vue.component('GroupItem', groupItemComponent); + const createComponent = ({ propsData } = {}) => { + wrapper = mountExtended(GroupsComponent, { + propsData: { + ...defaultPropsData, + ...propsData, + }, + provide: { + currentGroupVisibility: VISIBILITY_PRIVATE, + }, + }); + }; - vm = createComponent(); + const findPaginationLinks = () => wrapper.findComponent(PaginationLinks); - await nextTick(); + beforeEach(async () => { + Vue.component('GroupFolder', GroupFolderComponent); + Vue.component('GroupItem', GroupItemComponent); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); describe('methods', () => { describe('change', () => { it('should emit `fetchPage` event when page is changed via pagination', () => { + createComponent(); + jest.spyOn(eventHub, '$emit').mockImplementation(); - vm.change(2); + findPaginationLinks().props('change')(2); expect(eventHub.$emit).toHaveBeenCalledWith('fetchPage', { page: 2, @@ -52,18 +62,18 @@ describe('GroupsComponent', () => { }); describe('template', () => { - it('should render component template correctly', async () => { - await nextTick(); - expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined(); - expect(vm.$el.querySelector('.group-list-tree')).toBeDefined(); - expect(vm.$el.querySelector('.gl-pagination')).toBeDefined(); - expect(vm.$el.querySelectorAll('.has-no-search-results').length).toBe(0); + it('should render component template correctly', () => { + createComponent(); + + expect(wrapper.findComponent(GroupFolderComponent).exists()).toBe(true); + expect(findPaginationLinks().exists()).toBe(true); + expect(wrapper.findByText(defaultPropsData.searchEmptyMessage).exists()).toBe(false); }); - it('should render empty search message when `searchEmpty` is `true`', async () => { - vm.searchEmpty = true; - await nextTick(); - expect(vm.$el.querySelector('.has-no-search-results')).toBeDefined(); + it('should render empty search message when `searchEmpty` is `true`', () => { + createComponent({ propsData: { searchEmpty: true } }); + + expect(wrapper.findByText(defaultPropsData.searchEmptyMessage).exists()).toBe(true); }); }); }); diff --git a/spec/frontend/groups/mock_data.js b/spec/frontend/groups/mock_data.js index 603cb27deec..9a325776374 100644 --- a/spec/frontend/groups/mock_data.js +++ b/spec/frontend/groups/mock_data.js @@ -5,26 +5,6 @@ export const ITEM_TYPE = { GROUP: 'group', }; -export const GROUP_VISIBILITY_TYPE = { - public: 'Public - The group and any public projects can be viewed without any authentication.', - internal: - 'Internal - The group and any internal projects can be viewed by any logged in user except external users.', - private: 'Private - The group and its projects can only be viewed by members.', -}; - -export const PROJECT_VISIBILITY_TYPE = { - public: 'Public - The project can be accessed without any authentication.', - internal: 'Internal - The project can be accessed by any logged in user except external users.', - private: - 'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.', -}; - -export const VISIBILITY_TYPE_ICON = { - public: 'earth', - internal: 'shield', - private: 'lock', -}; - export const mockParentGroupItem = { id: 55, name: 'hardware', @@ -49,6 +29,7 @@ export const mockParentGroupItem = { isChildrenLoading: false, isBeingRemoved: false, updatedAt: '2017-04-09T18:40:39.101Z', + lastActivityAt: '2017-04-09T18:40:39.101Z', }; export const mockRawChildren = [ diff --git a/spec/frontend/header_search/components/app_spec.js b/spec/frontend/header_search/components/app_spec.js index f0de5b083ae..d89218f5542 100644 --- a/spec/frontend/header_search/components/app_spec.js +++ b/spec/frontend/header_search/components/app_spec.js @@ -1,22 +1,32 @@ -import { GlSearchBoxByType } from '@gitlab/ui'; +import { GlSearchBoxByType, GlToken, GlIcon } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { s__, sprintf } from '~/locale'; import HeaderSearchApp from '~/header_search/components/app.vue'; import HeaderSearchAutocompleteItems from '~/header_search/components/header_search_autocomplete_items.vue'; import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue'; import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue'; -import { SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION } from '~/header_search/constants'; +import { + SEARCH_INPUT_DESCRIPTION, + SEARCH_RESULTS_DESCRIPTION, + SEARCH_BOX_INDEX, + ICON_PROJECT, + ICON_GROUP, + ICON_SUBGROUP, + SCOPE_TOKEN_MAX_LENGTH, +} from '~/header_search/constants'; import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; import { ENTER_KEY } from '~/lib/utils/keys'; import { visitUrl } from '~/lib/utils/url_utility'; +import { truncate } from '~/lib/utils/text_utility'; import { MOCK_SEARCH, MOCK_SEARCH_QUERY, MOCK_USERNAME, MOCK_DEFAULT_SEARCH_OPTIONS, MOCK_SCOPED_SEARCH_OPTIONS, - MOCK_SORTED_AUTOCOMPLETE_OPTIONS, + MOCK_SEARCH_CONTEXT_FULL, } from '../mock_data'; Vue.use(Vuex); @@ -52,11 +62,27 @@ describe('HeaderSearchApp', () => { }); }; + const formatScopeName = (scopeName) => { + if (!scopeName) { + return false; + } + const searchResultsScope = s__('GlobalSearch|in %{scope}'); + return truncate( + sprintf(searchResultsScope, { + scope: scopeName, + }), + SCOPE_TOKEN_MAX_LENGTH, + ); + }; + afterEach(() => { wrapper.destroy(); }); + const findHeaderSearchForm = () => wrapper.findByTestId('header-search-form'); const findHeaderSearchInput = () => wrapper.findComponent(GlSearchBoxByType); + const findScopeToken = () => wrapper.findComponent(GlToken); + const findHeaderSearchInputKBD = () => wrapper.find('.keyboard-shortcut-helper'); const findHeaderSearchDropdown = () => wrapper.findByTestId('header-search-dropdown-menu'); const findHeaderSearchDefaultItems = () => wrapper.findComponent(HeaderSearchDefaultItems); const findHeaderSearchScopedItems = () => wrapper.findComponent(HeaderSearchScopedItems); @@ -76,6 +102,14 @@ describe('HeaderSearchApp', () => { expect(findHeaderSearchInput().exists()).toBe(true); }); + it('Header Search Input KBD hint', () => { + expect(findHeaderSearchInputKBD().exists()).toBe(true); + expect(findHeaderSearchInputKBD().text()).toContain('/'); + expect(findHeaderSearchInputKBD().attributes('title')).toContain( + 'Use the shortcut key <kbd>/</kbd> to start a search', + ); + }); + it('Search Input Description', () => { expect(findSearchInputDescription().exists()).toBe(true); }); @@ -106,53 +140,38 @@ describe('HeaderSearchApp', () => { }); describe.each` - search | showDefault | showScoped | showAutocomplete | showDropdownNavigation - ${null} | ${true} | ${false} | ${false} | ${true} - ${''} | ${true} | ${false} | ${false} | ${true} - ${'1'} | ${false} | ${false} | ${false} | ${false} - ${')'} | ${false} | ${false} | ${false} | ${false} - ${'t'} | ${false} | ${false} | ${true} | ${true} - ${'te'} | ${false} | ${true} | ${true} | ${true} - ${'tes'} | ${false} | ${true} | ${true} | ${true} - ${MOCK_SEARCH} | ${false} | ${true} | ${true} | ${true} - `( - 'Header Search Dropdown Items', - ({ search, showDefault, showScoped, showAutocomplete, showDropdownNavigation }) => { - describe(`when search is ${search}`, () => { - beforeEach(() => { - window.gon.current_username = MOCK_USERNAME; - createComponent( - { search }, - { - autocompleteGroupedSearchOptions: () => - search.match(/^[A-Za-z]+$/g) ? MOCK_SORTED_AUTOCOMPLETE_OPTIONS : [], - }, - ); - findHeaderSearchInput().vm.$emit('click'); - }); + search | showDefault | showScoped | showAutocomplete + ${null} | ${true} | ${false} | ${false} + ${''} | ${true} | ${false} | ${false} + ${'t'} | ${false} | ${false} | ${true} + ${'te'} | ${false} | ${false} | ${true} + ${'tes'} | ${false} | ${true} | ${true} + ${MOCK_SEARCH} | ${false} | ${true} | ${true} + `('Header Search Dropdown Items', ({ search, showDefault, showScoped, showAutocomplete }) => { + describe(`when search is ${search}`, () => { + beforeEach(() => { + window.gon.current_username = MOCK_USERNAME; + createComponent({ search }, {}); + findHeaderSearchInput().vm.$emit('click'); + }); - it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => { - expect(findHeaderSearchDefaultItems().exists()).toBe(showDefault); - }); + it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => { + expect(findHeaderSearchDefaultItems().exists()).toBe(showDefault); + }); - it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => { - expect(findHeaderSearchScopedItems().exists()).toBe(showScoped); - }); + it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => { + expect(findHeaderSearchScopedItems().exists()).toBe(showScoped); + }); - it(`should${ - showAutocomplete ? '' : ' not' - } render the Autocomplete Dropdown Items`, () => { - expect(findHeaderSearchAutocompleteItems().exists()).toBe(showAutocomplete); - }); + it(`should${showAutocomplete ? '' : ' not'} render the Autocomplete Dropdown Items`, () => { + expect(findHeaderSearchAutocompleteItems().exists()).toBe(showAutocomplete); + }); - it(`should${ - showDropdownNavigation ? '' : ' not' - } render the Dropdown Navigation Component`, () => { - expect(findDropdownKeyboardNavigation().exists()).toBe(showDropdownNavigation); - }); + it(`should render the Dropdown Navigation Component`, () => { + expect(findDropdownKeyboardNavigation().exists()).toBe(true); }); - }, - ); + }); + }); describe.each` username | showDropdown | expectedDesc @@ -185,12 +204,18 @@ describe('HeaderSearchApp', () => { `( 'Search Results Description', ({ username, showDropdown, search, loading, searchOptions, expectedDesc }) => { - describe(`search is ${search}, loading is ${loading}, and showSearchDropdown is ${ - Boolean(username) && showDropdown - }`, () => { + describe(`search is "${search}", loading is ${loading}, and showSearchDropdown is ${showDropdown}`, () => { beforeEach(() => { window.gon.current_username = username; - createComponent({ search, loading }, { searchOptions: () => searchOptions }); + createComponent( + { + search, + loading, + }, + { + searchOptions: () => searchOptions, + }, + ); findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : ''); }); @@ -200,6 +225,121 @@ describe('HeaderSearchApp', () => { }); }, ); + + describe('input box', () => { + describe.each` + search | searchOptions | hasToken + ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[0]]} | ${true} + ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[1]]} | ${true} + ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[2]]} | ${true} + ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[3]]} | ${true} + ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[4]]} | ${true} + ${'te'} | ${[MOCK_SCOPED_SEARCH_OPTIONS[5]]} | ${false} + ${'x'} | ${[]} | ${false} + `('token', ({ search, searchOptions, hasToken }) => { + beforeEach(() => { + window.gon.current_username = MOCK_USERNAME; + createComponent( + { search }, + { + searchOptions: () => searchOptions, + }, + ); + }); + + it(`${hasToken ? 'is' : 'is NOT'} rendered when data set has type "${ + searchOptions[0]?.html_id + }"`, () => { + expect(findScopeToken().exists()).toBe(hasToken); + }); + + it(`text ${hasToken ? 'is correctly' : 'is NOT'} rendered when text is "${ + searchOptions[0]?.scope || searchOptions[0]?.description + }"`, () => { + expect(findScopeToken().exists() && findScopeToken().text()).toBe( + formatScopeName(searchOptions[0]?.scope || searchOptions[0]?.description), + ); + }); + }); + }); + + describe('form wrapper', () => { + describe.each` + searchContext | search | searchOptions + ${MOCK_SEARCH_CONTEXT_FULL} | ${null} | ${[]} + ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${[]} + ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} + ${null} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} + ${null} | ${null} | ${MOCK_SCOPED_SEARCH_OPTIONS} + ${null} | ${null} | ${[]} + `('', ({ searchContext, search, searchOptions }) => { + beforeEach(() => { + window.gon.current_username = MOCK_USERNAME; + + createComponent({ search, searchContext }, { searchOptions: () => searchOptions }); + + findHeaderSearchInput().vm.$emit('click'); + }); + + const hasIcon = Boolean(searchContext?.group); + const isSearching = Boolean(search); + const isActive = Boolean(searchOptions.length > 0); + + it(`${hasIcon ? 'with' : 'without'} search context classes contain "${ + hasIcon ? 'has-icon' : 'has-no-icon' + }"`, () => { + const iconClassRegex = hasIcon ? 'has-icon' : 'has-no-icon'; + expect(findHeaderSearchForm().classes()).toContain(iconClassRegex); + }); + + it(`${isSearching ? 'with' : 'without'} search string classes contain "${ + isSearching ? 'is-searching' : 'is-not-searching' + }"`, () => { + const iconClassRegex = isSearching ? 'is-searching' : 'is-not-searching'; + expect(findHeaderSearchForm().classes()).toContain(iconClassRegex); + }); + + it(`${isActive ? 'with' : 'without'} search results classes contain "${ + isActive ? 'is-active' : 'is-not-active' + }"`, () => { + const iconClassRegex = isActive ? 'is-active' : 'is-not-active'; + expect(findHeaderSearchForm().classes()).toContain(iconClassRegex); + }); + }); + }); + + describe.each` + search | searchOptions | hasIcon | iconName + ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[0]]} | ${true} | ${ICON_PROJECT} + ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[2]]} | ${true} | ${ICON_GROUP} + ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[3]]} | ${true} | ${ICON_SUBGROUP} + ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[4]]} | ${false} | ${false} + `('token', ({ search, searchOptions, hasIcon, iconName }) => { + beforeEach(() => { + window.gon.current_username = MOCK_USERNAME; + createComponent( + { search }, + { + searchOptions: () => searchOptions, + }, + ); + }); + + it(`icon for data set type "${searchOptions[0]?.html_id}" ${ + hasIcon ? 'is' : 'is NOT' + } rendered`, () => { + expect(findScopeToken().findComponent(GlIcon).exists()).toBe(hasIcon); + }); + + it(`render ${iconName ? `"${iconName}"` : 'NO'} icon for data set type "${ + searchOptions[0]?.html_id + }"`, () => { + expect( + findScopeToken().findComponent(GlIcon).exists() && + findScopeToken().findComponent(GlIcon).attributes('name'), + ).toBe(iconName); + }); + }); }); describe('events', () => { @@ -285,18 +425,20 @@ describe('HeaderSearchApp', () => { }); describe('computed', () => { - describe('currentFocusedOption', () => { - const MOCK_INDEX = 1; - + describe.each` + MOCK_INDEX | search + ${1} | ${null} + ${SEARCH_BOX_INDEX} | ${'test'} + ${2} | ${'test1'} + `('currentFocusedOption', ({ MOCK_INDEX, search }) => { beforeEach(() => { - createComponent(); + createComponent({ search }); window.gon.current_username = MOCK_USERNAME; findHeaderSearchInput().vm.$emit('click'); }); - it(`when currentFocusIndex changes to ${MOCK_INDEX} updates the data to searchOptions[${MOCK_INDEX}]`, async () => { + it(`when currentFocusIndex changes to ${MOCK_INDEX} updates the data to searchOptions[${MOCK_INDEX}]`, () => { findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX); - await nextTick(); expect(wrapper.vm.currentFocusedOption).toBe(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX]); }); }); @@ -308,15 +450,25 @@ describe('HeaderSearchApp', () => { createComponent(); }); - it('onKey-enter submits a search', async () => { + it('onKey-enter submits a search', () => { findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); - await nextTick(); - expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY); }); }); + describe('with less than min characters and no dropdown results', () => { + beforeEach(() => { + createComponent({ search: 'x' }); + }); + + it('onKey-enter will NOT submit a search', () => { + findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); + + expect(visitUrl).not.toHaveBeenCalledWith(MOCK_SEARCH_QUERY); + }); + }); + describe('with currentFocusedOption', () => { const MOCK_INDEX = 1; @@ -326,9 +478,9 @@ describe('HeaderSearchApp', () => { findHeaderSearchInput().vm.$emit('click'); }); - it('onKey-enter clicks the selected dropdown item rather than submitting a search', async () => { + it('onKey-enter clicks the selected dropdown item rather than submitting a search', () => { findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX); - await nextTick(); + findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); expect(visitUrl).toHaveBeenCalledWith(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX].url); }); diff --git a/spec/frontend/header_search/components/header_search_scoped_items_spec.js b/spec/frontend/header_search/components/header_search_scoped_items_spec.js index 8788fb23458..2db9f71d702 100644 --- a/spec/frontend/header_search/components/header_search_scoped_items_spec.js +++ b/spec/frontend/header_search/components/header_search_scoped_items_spec.js @@ -1,9 +1,11 @@ -import { GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; +import { GlDropdownItem, GlToken, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; import { trimText } from 'helpers/text_helper'; import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue'; +import { truncate } from '~/lib/utils/text_utility'; +import { MSG_IN_ALL_GITLAB, SCOPE_TOKEN_MAX_LENGTH } from '~/header_search/constants'; import { MOCK_SEARCH, MOCK_SCOPED_SEARCH_OPTIONS, @@ -41,9 +43,12 @@ describe('HeaderSearchScopedItems', () => { }); const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findGlDropdownDivider = () => wrapper.findComponent(GlDropdownDivider); const findFirstDropdownItem = () => findDropdownItems().at(0); const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => trimText(w.text())); + const findScopeTokens = () => wrapper.findAllComponents(GlToken); + const findScopeTokensText = () => findScopeTokens().wrappers.map((w) => trimText(w.text())); + const findScopeTokensIcons = () => + findScopeTokens().wrappers.map((w) => w.findAllComponents(GlIcon)); const findDropdownItemAriaLabels = () => findDropdownItems().wrappers.map((w) => trimText(w.attributes('aria-label'))); const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); @@ -59,15 +64,31 @@ describe('HeaderSearchScopedItems', () => { }); it('renders titles correctly', () => { + findDropdownItemTitles().forEach((title) => expect(title).toContain(MOCK_SEARCH)); + }); + + it('renders scope names correctly', () => { const expectedTitles = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => - trimText(`"${MOCK_SEARCH}" ${o.description} ${o.scope || ''}`), + truncate(trimText(`in ${o.description || o.scope}`), SCOPE_TOKEN_MAX_LENGTH), ); - expect(findDropdownItemTitles()).toStrictEqual(expectedTitles); + + expect(findScopeTokensText()).toStrictEqual(expectedTitles); + }); + + it('renders scope icons correctly', () => { + findScopeTokensIcons().forEach((icon, i) => { + const w = icon.wrappers[0]; + expect(w?.attributes('name')).toBe(MOCK_SCOPED_SEARCH_OPTIONS[i].icon); + }); + }); + + it(`renders scope ${MSG_IN_ALL_GITLAB} correctly`, () => { + expect(findScopeTokens().at(-1).findComponent(GlIcon).exists()).toBe(false); }); it('renders aria-labels correctly', () => { const expectedLabels = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => - trimText(`${MOCK_SEARCH} ${o.description} ${o.scope || ''}`), + trimText(`${MOCK_SEARCH} ${o.description || o.icon} ${o.scope || ''}`), ); expect(findDropdownItemAriaLabels()).toStrictEqual(expectedLabels); }); @@ -98,21 +119,5 @@ describe('HeaderSearchScopedItems', () => { }); }); }); - - describe.each` - autosuggestResults | showDivider - ${[]} | ${false} - ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${true} - `('scoped search items', ({ autosuggestResults, showDivider }) => { - describe(`when when we have ${autosuggestResults.length} auto-sugest results`, () => { - beforeEach(() => { - createComponent({}, { autocompleteGroupedSearchOptions: () => autosuggestResults }, {}); - }); - - it(`divider should${showDivider ? '' : ' not'} be shown`, () => { - expect(findGlDropdownDivider().exists()).toBe(showDivider); - }); - }); - }); }); }); diff --git a/spec/frontend/header_search/init_spec.js b/spec/frontend/header_search/init_spec.js new file mode 100644 index 00000000000..9515ca8c812 --- /dev/null +++ b/spec/frontend/header_search/init_spec.js @@ -0,0 +1,74 @@ +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; + +import initHeaderSearch, { eventHandler, cleanEventListeners } from '~/header_search/init'; + +describe('Header Search EventListener', () => { + beforeEach(() => { + jest.resetModules(); + jest.restoreAllMocks(); + setHTMLFixture(` + <div class="js-header-content"> + <div class="header-search" id="js-header-search" data-autocomplete-path="/search/autocomplete" data-issues-path="/dashboard/issues" data-mr-path="/dashboard/merge_requests" data-search-context="{}" data-search-path="/search"> + <input autocomplete="off" class="form-control gl-form-input gl-search-box-by-type-input" data-qa-selector="search_box" id="search" name="search" placeholder="Search GitLab" type="text"> + </div> + </div>`); + }); + + afterEach(() => { + resetHTMLFixture(); + jest.clearAllMocks(); + }); + + it('attached event listener', () => { + const searchInputBox = document?.querySelector('#search'); + const addEventListenerSpy = jest.spyOn(searchInputBox, 'addEventListener'); + initHeaderSearch(); + + expect(addEventListenerSpy).toBeCalledTimes(2); + }); + + it('removes event listener ', async () => { + const searchInputBox = document?.querySelector('#search'); + const removeEventListenerSpy = jest.spyOn(searchInputBox, 'removeEventListener'); + jest.mock('~/header_search', () => ({ initHeaderSearchApp: jest.fn() })); + await eventHandler.apply( + { + newHeaderSearchFeatureFlag: true, + searchInputBox: document.querySelector('#search'), + }, + [cleanEventListeners], + ); + + expect(removeEventListenerSpy).toBeCalledTimes(2); + }); + + it('attaches new vue dropdown when feature flag is enabled', async () => { + const mockVueApp = jest.fn(); + jest.mock('~/header_search', () => ({ initHeaderSearchApp: mockVueApp })); + await eventHandler.apply( + { + newHeaderSearchFeatureFlag: true, + searchInputBox: document.querySelector('#search'), + }, + () => {}, + ); + + expect(mockVueApp).toBeCalled(); + }); + + it('attaches old vue dropdown when feature flag is disabled', async () => { + const mockLegacyApp = jest.fn(() => ({ + onSearchInputFocus: jest.fn(), + })); + jest.mock('~/search_autocomplete', () => mockLegacyApp); + await eventHandler.apply( + { + newHeaderSearchFeatureFlag: false, + searchInputBox: document.querySelector('#search'), + }, + () => {}, + ); + + expect(mockLegacyApp).toBeCalled(); + }); +}); diff --git a/spec/frontend/header_search/mock_data.js b/spec/frontend/header_search/mock_data.js index b6f0fdcc29d..8ccd7fb17e3 100644 --- a/spec/frontend/header_search/mock_data.js +++ b/spec/frontend/header_search/mock_data.js @@ -4,9 +4,12 @@ import { MSG_MR_ASSIGNED_TO_ME, MSG_MR_IM_REVIEWER, MSG_MR_IVE_CREATED, - MSG_IN_PROJECT, - MSG_IN_GROUP, MSG_IN_ALL_GITLAB, + PROJECTS_CATEGORY, + ICON_PROJECT, + GROUPS_CATEGORY, + ICON_GROUP, + ICON_SUBGROUP, } from '~/header_search/constants'; export const MOCK_USERNAME = 'anyone'; @@ -27,12 +30,24 @@ export const MOCK_PROJECT = { path: '/mock-project', }; +export const MOCK_PROJECT_LONG = { + id: 124, + name: 'Mock Project Name That Is Ridiculously Long And It Goes Forever', + path: '/mock-project-name-that-is-ridiculously-long-and-it-goes-forever', +}; + export const MOCK_GROUP = { id: 321, name: 'MockGroup', path: '/mock-group', }; +export const MOCK_SUBGROUP = { + id: 322, + name: 'MockSubGroup', + path: `${MOCK_GROUP}/mock-subgroup`, +}; + export const MOCK_SEARCH_QUERY = 'http://gitlab.com/search?search=test'; export const MOCK_SEARCH = 'test'; @@ -44,6 +59,20 @@ export const MOCK_SEARCH_CONTEXT = { group_metadata: {}, }; +export const MOCK_SEARCH_CONTEXT_FULL = { + group: { + id: 31, + name: 'testGroup', + full_name: 'testGroup', + }, + group_metadata: { + group_path: 'testGroup', + name: 'testGroup', + issues_path: '/groups/testGroup/-/issues', + mr_path: '/groups/testGroup/-/merge_requests', + }, +}; + export const MOCK_DEFAULT_SEARCH_OPTIONS = [ { html_id: 'default-issues-assigned', @@ -76,13 +105,51 @@ export const MOCK_SCOPED_SEARCH_OPTIONS = [ { html_id: 'scoped-in-project', scope: MOCK_PROJECT.name, - description: MSG_IN_PROJECT, + scopeCategory: PROJECTS_CATEGORY, + icon: ICON_PROJECT, + url: MOCK_PROJECT.path, + }, + { + html_id: 'scoped-in-project-long', + scope: MOCK_PROJECT_LONG.name, + scopeCategory: PROJECTS_CATEGORY, + icon: ICON_PROJECT, + url: MOCK_PROJECT_LONG.path, + }, + { + html_id: 'scoped-in-group', + scope: MOCK_GROUP.name, + scopeCategory: GROUPS_CATEGORY, + icon: ICON_GROUP, + url: MOCK_GROUP.path, + }, + { + html_id: 'scoped-in-subgroup', + scope: MOCK_SUBGROUP.name, + scopeCategory: GROUPS_CATEGORY, + icon: ICON_SUBGROUP, + url: MOCK_SUBGROUP.path, + }, + { + html_id: 'scoped-in-all', + description: MSG_IN_ALL_GITLAB, + url: MOCK_ALL_PATH, + }, +]; + +export const MOCK_SCOPED_SEARCH_OPTIONS_DEF = [ + { + html_id: 'scoped-in-project', + scope: MOCK_PROJECT.name, + scopeCategory: PROJECTS_CATEGORY, + icon: ICON_PROJECT, url: MOCK_PROJECT.path, }, { html_id: 'scoped-in-group', scope: MOCK_GROUP.name, - description: MSG_IN_GROUP, + scopeCategory: GROUPS_CATEGORY, + icon: ICON_GROUP, url: MOCK_GROUP.path, }, { diff --git a/spec/frontend/header_search/store/getters_spec.js b/spec/frontend/header_search/store/getters_spec.js index d3510de1439..c76be3c0360 100644 --- a/spec/frontend/header_search/store/getters_spec.js +++ b/spec/frontend/header_search/store/getters_spec.js @@ -9,6 +9,7 @@ import { MOCK_SEARCH_CONTEXT, MOCK_DEFAULT_SEARCH_OPTIONS, MOCK_SCOPED_SEARCH_OPTIONS, + MOCK_SCOPED_SEARCH_OPTIONS_DEF, MOCK_PROJECT, MOCK_GROUP, MOCK_ALL_PATH, @@ -284,7 +285,7 @@ describe('Header Search Store Getters', () => { it('returns the correct array', () => { expect(getters.scopedSearchOptions(state, mockGetters)).toStrictEqual( - MOCK_SCOPED_SEARCH_OPTIONS, + MOCK_SCOPED_SEARCH_OPTIONS_DEF, ); }); }); @@ -308,6 +309,11 @@ describe('Header Search Store Getters', () => { ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${[]} | ${MOCK_SCOPED_SEARCH_OPTIONS} ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS} ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS.concat(MOCK_SORTED_AUTOCOMPLETE_OPTIONS)} + ${1} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${[]} | ${[]} + ${'('} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${[]} | ${[]} + ${'t'} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS} + ${'te'} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS} + ${'tes'} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS.concat(MOCK_SORTED_AUTOCOMPLETE_OPTIONS)} `( 'searchOptions', ({ diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js index 19849fba63c..4e2fb70a2cb 100644 --- a/spec/frontend/header_spec.js +++ b/spec/frontend/header_spec.js @@ -1,4 +1,3 @@ -import $ from 'jquery'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import initTodoToggle, { initNavUserDropdownTracking } from '~/header'; import { loadHTMLFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; @@ -9,11 +8,17 @@ describe('Header', () => { const fixtureTemplate = 'issues/open-issue.html'; function isTodosCountHidden() { - return $(todosPendingCount).hasClass('hidden'); + return document.querySelector(todosPendingCount).classList.contains('hidden'); } function triggerToggle(newCount) { - $(document).trigger('todo:toggle', newCount); + const event = new CustomEvent('todo:toggle', { + detail: { + count: newCount, + }, + }); + + document.dispatchEvent(event); } beforeEach(() => { @@ -28,7 +33,7 @@ describe('Header', () => { it('should update todos-count after receiving the todo:toggle event', () => { triggerToggle(5); - expect($(todosPendingCount).text()).toEqual('5'); + expect(document.querySelector(todosPendingCount).textContent).toEqual('5'); }); it('should hide todos-count when it is 0', () => { @@ -53,7 +58,7 @@ describe('Header', () => { }); it('should show 99+ for todos-count', () => { - expect($(todosPendingCount).text()).toEqual('99+'); + expect(document.querySelector(todosPendingCount).textContent).toEqual('99+'); }); }); }); @@ -67,7 +72,11 @@ describe('Header', () => { <a class="js-buy-pipeline-minutes-link" data-track-action="click_buy_ci_minutes" data-track-label="free" data-track-property="user_dropdown">Buy Pipeline minutes</a> </li>`); - trackingSpy = mockTracking('_category_', $('.js-nav-user-dropdown').element, jest.spyOn); + trackingSpy = mockTracking( + '_category_', + document.querySelector('.js-nav-user-dropdown').element, + jest.spyOn, + ); document.body.dataset.page = 'some:page'; initNavUserDropdownTracking(); @@ -79,7 +88,8 @@ describe('Header', () => { }); it('sends a tracking event when the dropdown is opened and contains Buy Pipeline minutes link', () => { - $('.js-nav-user-dropdown').trigger('shown.bs.dropdown'); + const event = new CustomEvent('shown.bs.dropdown'); + document.querySelector('.js-nav-user-dropdown').dispatchEvent(event); expect(trackingSpy).toHaveBeenCalledWith('some:page', 'show_buy_ci_minutes', { label: 'free', diff --git a/spec/frontend/ide/components/commit_sidebar/empty_state_spec.js b/spec/frontend/ide/components/commit_sidebar/empty_state_spec.js index 4f81c0aa5d3..7c48c0e6f95 100644 --- a/spec/frontend/ide/components/commit_sidebar/empty_state_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/empty_state_spec.js @@ -1,29 +1,21 @@ -import Vue from 'vue'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; -import emptyState from '~/ide/components/commit_sidebar/empty_state.vue'; +import { shallowMount } from '@vue/test-utils'; +import EmptyState from '~/ide/components/commit_sidebar/empty_state.vue'; import { createStore } from '~/ide/stores'; -describe('IDE commit panel empty state', () => { - let vm; - let store; +describe('IDE commit panel EmptyState component', () => { + let wrapper; beforeEach(() => { - store = createStore(); - - const Component = Vue.extend(emptyState); - - Vue.set(store.state, 'noChangesStateSvgPath', 'no-changes'); - - vm = createComponentWithStore(Component, store); - - vm.$mount(); + const store = createStore(); + store.state.noChangesStateSvgPath = 'no-changes'; + wrapper = shallowMount(EmptyState, { store }); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('renders no changes text when last commit message is empty', () => { - expect(vm.$el.textContent).toContain('No changes'); + expect(wrapper.find('h4').text()).toBe('No changes'); }); }); diff --git a/spec/frontend/ide/components/commit_sidebar/list_spec.js b/spec/frontend/ide/components/commit_sidebar/list_spec.js index 1d42512c9ee..81c81fc0a9f 100644 --- a/spec/frontend/ide/components/commit_sidebar/list_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/list_spec.js @@ -1,51 +1,47 @@ -import Vue, { nextTick } from 'vue'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; -import commitSidebarList from '~/ide/components/commit_sidebar/list.vue'; -import { createStore } from '~/ide/stores'; +import { shallowMount } from '@vue/test-utils'; +import CommitSidebarList from '~/ide/components/commit_sidebar/list.vue'; +import ListItem from '~/ide/components/commit_sidebar/list_item.vue'; import { file } from '../../helpers'; describe('Multi-file editor commit sidebar list', () => { - let store; - let vm; - - beforeEach(() => { - store = createStore(); - - const Component = Vue.extend(commitSidebarList); - - vm = createComponentWithStore(Component, store, { - title: 'Staged', - fileList: [], - action: 'stageAllChanges', - actionBtnText: 'stage all', - actionBtnIcon: 'history', - activeFileKey: 'staged-testing', - keyPrefix: 'staged', + let wrapper; + + const mountComponent = ({ fileList }) => + shallowMount(CommitSidebarList, { + propsData: { + title: 'Staged', + fileList, + action: 'stageAllChanges', + actionBtnText: 'stage all', + actionBtnIcon: 'history', + activeFileKey: 'staged-testing', + keyPrefix: 'staged', + }, }); - vm.$mount(); - }); - afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); describe('with a list of files', () => { beforeEach(async () => { const f = file('file name'); f.changed = true; - vm.fileList.push(f); - await nextTick(); + wrapper = mountComponent({ fileList: [f] }); }); it('renders list', () => { - expect(vm.$el.querySelectorAll('.multi-file-commit-list > li').length).toBe(1); + expect(wrapper.findAllComponents(ListItem)).toHaveLength(1); }); }); - describe('empty files array', () => { - it('renders no changes text when empty', () => { - expect(vm.$el.textContent).toContain('No changes'); + describe('with empty files array', () => { + beforeEach(() => { + wrapper = mountComponent({ fileList: [] }); + }); + + it('renders no changes text ', () => { + expect(wrapper.text()).toContain('No changes'); }); }); }); diff --git a/spec/frontend/ide/components/commit_sidebar/success_message_spec.js b/spec/frontend/ide/components/commit_sidebar/success_message_spec.js index 52e35bdbb73..63d51953915 100644 --- a/spec/frontend/ide/components/commit_sidebar/success_message_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/success_message_spec.js @@ -1,32 +1,22 @@ -import Vue, { nextTick } from 'vue'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; -import successMessage from '~/ide/components/commit_sidebar/success_message.vue'; +import { shallowMount } from '@vue/test-utils'; +import SuccessMessage from '~/ide/components/commit_sidebar/success_message.vue'; import { createStore } from '~/ide/stores'; describe('IDE commit panel successful commit state', () => { - let vm; - let store; + let wrapper; beforeEach(() => { - store = createStore(); - - const Component = Vue.extend(successMessage); - - vm = createComponentWithStore(Component, store, { - committedStateSvgPath: 'committed-state', - }); - - vm.$mount(); + const store = createStore(); + store.state.committedStateSvgPath = 'committed-state'; + store.state.lastCommitMsg = 'testing commit message'; + wrapper = shallowMount(SuccessMessage, { store }); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); - it('renders last commit message when it exists', async () => { - vm.$store.state.lastCommitMsg = 'testing commit message'; - - await nextTick(); - expect(vm.$el.textContent).toContain('testing commit message'); + it('renders last commit message when it exists', () => { + expect(wrapper.text()).toContain('testing commit message'); }); }); diff --git a/spec/frontend/ide/components/ide_spec.js b/spec/frontend/ide/components/ide_spec.js index 37b42001a80..9172c69b10e 100644 --- a/spec/frontend/ide/components/ide_spec.js +++ b/spec/frontend/ide/components/ide_spec.js @@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; import waitForPromises from 'helpers/wait_for_promises'; +import { stubPerformanceWebAPI } from 'helpers/performance'; import CannotPushCodeAlert from '~/ide/components/cannot_push_code_alert.vue'; import ErrorMessage from '~/ide/components/error_message.vue'; import Ide from '~/ide/components/ide.vue'; @@ -40,6 +41,8 @@ describe('WebIDE', () => { const findAlert = () => wrapper.findComponent(CannotPushCodeAlert); beforeEach(() => { + stubPerformanceWebAPI(); + store = createStore(); }); diff --git a/spec/frontend/ide/components/ide_tree_list_spec.js b/spec/frontend/ide/components/ide_tree_list_spec.js index a85c52f5e86..0f61aa80e53 100644 --- a/spec/frontend/ide/components/ide_tree_list_spec.js +++ b/spec/frontend/ide/components/ide_tree_list_spec.js @@ -1,82 +1,72 @@ -import Vue, { nextTick } from 'vue'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { GlSkeletonLoader } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import IdeTreeList from '~/ide/components/ide_tree_list.vue'; import { createStore } from '~/ide/stores'; +import FileTree from '~/vue_shared/components/file_tree.vue'; import { file } from '../helpers'; import { projectData } from '../mock_data'; -describe('IDE tree list', () => { - const Component = Vue.extend(IdeTreeList); - const normalBranchTree = [file('fileName')]; - const emptyBranchTree = []; - let vm; - let store; +describe('IdeTreeList component', () => { + let wrapper; - const bootstrapWithTree = (tree = normalBranchTree) => { + const mountComponent = ({ tree, loading = false } = {}) => { + const store = createStore(); store.state.currentProjectId = 'abcproject'; store.state.currentBranchId = 'main'; store.state.projects.abcproject = { ...projectData }; - Vue.set(store.state.trees, 'abcproject/main', { - tree, - loading: false, - }); + Vue.set(store.state.trees, 'abcproject/main', { tree, loading }); - vm = createComponentWithStore(Component, store, { - viewerType: 'edit', + wrapper = shallowMount(IdeTreeList, { + propsData: { + viewerType: 'edit', + }, + store, }); }; - beforeEach(() => { - store = createStore(); - }); - afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); describe('normal branch', () => { - beforeEach(() => { - bootstrapWithTree(); - - jest.spyOn(vm, '$emit').mockImplementation(() => {}); - - vm.$mount(); - }); + const tree = [file('fileName')]; it('emits tree-ready event', () => { - expect(vm.$emit).toHaveBeenCalledTimes(1); - expect(vm.$emit).toHaveBeenCalledWith('tree-ready'); + mountComponent({ tree }); + + expect(wrapper.emitted('tree-ready')).toEqual([[]]); }); - it('renders loading indicator', async () => { - store.state.trees['abcproject/main'].loading = true; + it('renders loading indicator', () => { + mountComponent({ tree, loading: true }); - await nextTick(); - expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull(); - expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3); + expect(wrapper.findAllComponents(GlSkeletonLoader)).toHaveLength(3); }); it('renders list of files', () => { - expect(vm.$el.textContent).toContain('fileName'); + mountComponent({ tree }); + + expect(wrapper.findAllComponents(FileTree)).toHaveLength(1); + expect(wrapper.findComponent(FileTree).props('file')).toEqual(tree[0]); }); }); describe('empty-branch state', () => { beforeEach(() => { - bootstrapWithTree(emptyBranchTree); - - jest.spyOn(vm, '$emit').mockImplementation(() => {}); + mountComponent({ tree: [] }); + }); - vm.$mount(); + it('emits tree-ready event', () => { + expect(wrapper.emitted('tree-ready')).toEqual([[]]); }); - it('still emits tree-ready event', () => { - expect(vm.$emit).toHaveBeenCalledWith('tree-ready'); + it('does not render files', () => { + expect(wrapper.findAllComponents(FileTree)).toHaveLength(0); }); - it('does not load files if the branch is empty', () => { - expect(vm.$el.textContent).not.toContain('fileName'); - expect(vm.$el.textContent).toContain('No files'); + it('renders empty state text', () => { + expect(wrapper.text()).toBe('No files'); }); }); }); diff --git a/spec/frontend/ide/components/nav_dropdown_button_spec.js b/spec/frontend/ide/components/nav_dropdown_button_spec.js index 1c14685df68..8eebcdd9e08 100644 --- a/spec/frontend/ide/components/nav_dropdown_button_spec.js +++ b/spec/frontend/ide/components/nav_dropdown_button_spec.js @@ -1,81 +1,74 @@ -import Vue, { nextTick } from 'vue'; import { trimText } from 'helpers/text_helper'; -import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import NavDropdownButton from '~/ide/components/nav_dropdown_button.vue'; import { createStore } from '~/ide/stores'; +import { __ } from '~/locale'; -describe('NavDropdown', () => { +describe('NavDropdownButton component', () => { const TEST_BRANCH_ID = 'lorem-ipsum-dolar'; const TEST_MR_ID = '12345'; - let store; - let vm; - - beforeEach(() => { - store = createStore(); - }); + let wrapper; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); - const createComponent = (props = {}) => { - vm = mountComponentWithStore(Vue.extend(NavDropdownButton), { props, store }); - vm.$mount(); + const createComponent = ({ props = {}, state = {} } = {}) => { + const store = createStore(); + store.replaceState(state); + wrapper = mountExtended(NavDropdownButton, { propsData: props, store }); }; - const findIcon = (name) => vm.$el.querySelector(`[data-testid="${name}-icon"]`); - const findMRIcon = () => findIcon('merge-request'); - const findBranchIcon = () => findIcon('branch'); + const findMRIcon = () => wrapper.findByLabelText(__('Merge request')); + const findBranchIcon = () => wrapper.findByLabelText(__('Current Branch')); describe('normal', () => { - beforeEach(() => { + it('renders empty placeholders, if state is falsey', () => { createComponent(); - }); - it('renders empty placeholders, if state is falsey', () => { - expect(trimText(vm.$el.textContent)).toEqual('- -'); + expect(trimText(wrapper.text())).toBe('- -'); }); - it('renders branch name, if state has currentBranchId', async () => { - vm.$store.state.currentBranchId = TEST_BRANCH_ID; + it('renders branch name, if state has currentBranchId', () => { + createComponent({ state: { currentBranchId: TEST_BRANCH_ID } }); - await nextTick(); - expect(trimText(vm.$el.textContent)).toEqual(`${TEST_BRANCH_ID} -`); + expect(trimText(wrapper.text())).toBe(`${TEST_BRANCH_ID} -`); }); - it('renders mr id, if state has currentMergeRequestId', async () => { - vm.$store.state.currentMergeRequestId = TEST_MR_ID; + it('renders mr id, if state has currentMergeRequestId', () => { + createComponent({ state: { currentMergeRequestId: TEST_MR_ID } }); - await nextTick(); - expect(trimText(vm.$el.textContent)).toEqual(`- !${TEST_MR_ID}`); + expect(trimText(wrapper.text())).toBe(`- !${TEST_MR_ID}`); }); - it('renders branch and mr, if state has both', async () => { - vm.$store.state.currentBranchId = TEST_BRANCH_ID; - vm.$store.state.currentMergeRequestId = TEST_MR_ID; + it('renders branch and mr, if state has both', () => { + createComponent({ + state: { currentBranchId: TEST_BRANCH_ID, currentMergeRequestId: TEST_MR_ID }, + }); - await nextTick(); - expect(trimText(vm.$el.textContent)).toEqual(`${TEST_BRANCH_ID} !${TEST_MR_ID}`); + expect(trimText(wrapper.text())).toBe(`${TEST_BRANCH_ID} !${TEST_MR_ID}`); }); it('shows icons', () => { - expect(findBranchIcon()).toBeTruthy(); - expect(findMRIcon()).toBeTruthy(); + createComponent(); + + expect(findBranchIcon().exists()).toBe(true); + expect(findMRIcon().exists()).toBe(true); }); }); - describe('with showMergeRequests false', () => { + describe('when showMergeRequests=false', () => { beforeEach(() => { - createComponent({ showMergeRequests: false }); + createComponent({ props: { showMergeRequests: false } }); }); it('shows single empty placeholder, if state is falsey', () => { - expect(trimText(vm.$el.textContent)).toEqual('-'); + expect(trimText(wrapper.text())).toBe('-'); }); it('shows only branch icon', () => { - expect(findBranchIcon()).toBeTruthy(); - expect(findMRIcon()).toBe(null); + expect(findBranchIcon().exists()).toBe(true); + expect(findMRIcon().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/ide/components/new_dropdown/modal_spec.js b/spec/frontend/ide/components/new_dropdown/modal_spec.js index e8635444801..68cc08d2ebc 100644 --- a/spec/frontend/ide/components/new_dropdown/modal_spec.js +++ b/spec/frontend/ide/components/new_dropdown/modal_spec.js @@ -1,209 +1,419 @@ -import Vue, { nextTick } from 'vue'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { GlButton, GlModal } from '@gitlab/ui'; +import { nextTick } from 'vue'; import createFlash from '~/flash'; -import modal from '~/ide/components/new_dropdown/modal.vue'; +import Modal from '~/ide/components/new_dropdown/modal.vue'; import { createStore } from '~/ide/stores'; +import { stubComponent } from 'helpers/stub_component'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { createEntriesFromPaths } from '../../helpers'; jest.mock('~/flash'); +const NEW_NAME = 'babar'; + describe('new file modal component', () => { - const Component = Vue.extend(modal); - let vm; + const showModal = jest.fn(); + const toggleModal = jest.fn(); + + let store; + let wrapper; + + const findForm = () => wrapper.findByTestId('file-name-form'); + const findGlModal = () => wrapper.findComponent(GlModal); + const findInput = () => wrapper.findByTestId('file-name-field'); + const findTemplateButtons = () => wrapper.findAllComponents(GlButton); + const findTemplateButtonsModel = () => + findTemplateButtons().wrappers.map((x) => ({ + text: x.text(), + variant: x.props('variant'), + category: x.props('category'), + })); + + const open = (type, path) => { + // TODO: This component can not be passed props + // We have to interact with the open() method? + wrapper.vm.open(type, path); + }; + const triggerSubmitForm = () => { + findForm().trigger('submit'); + }; + const triggerSubmitModal = () => { + findGlModal().vm.$emit('primary'); + }; + const triggerCancel = () => { + findGlModal().vm.$emit('cancel'); + }; + + const mountComponent = () => { + const GlModalStub = stubComponent(GlModal); + jest.spyOn(GlModalStub.methods, 'show').mockImplementation(showModal); + jest.spyOn(GlModalStub.methods, 'toggle').mockImplementation(toggleModal); + + wrapper = shallowMountExtended(Modal, { + store, + stubs: { + GlModal: GlModalStub, + }, + // We need to attach to document for "focus" to work + attachTo: document.body, + }); + }; + + beforeEach(() => { + store = createStore(); + + Object.assign( + store.state.entries, + createEntriesFromPaths([ + 'README.md', + 'src', + 'src/deleted.js', + 'src/parent_dir', + 'src/parent_dir/foo.js', + ]), + ); + Object.assign(store.state.entries['src/deleted.js'], { deleted: true }); + + jest.spyOn(store, 'dispatch').mockImplementation(); + }); afterEach(() => { - vm.$destroy(); + store = null; + wrapper.destroy(); + document.body.innerHTML = ''; }); - describe.each` - entryType | modalTitle | btnTitle | showsFileTemplates - ${'tree'} | ${'Create new directory'} | ${'Create directory'} | ${false} - ${'blob'} | ${'Create new file'} | ${'Create file'} | ${true} - `('$entryType', ({ entryType, modalTitle, btnTitle, showsFileTemplates }) => { + describe('default', () => { beforeEach(async () => { - const store = createStore(); - - vm = createComponentWithStore(Component, store).$mount(); - vm.open(entryType); - vm.name = 'testing'; + mountComponent(); + // Not necessarily needed, but used to ensure that nothing extra is happening after the tick await nextTick(); }); - afterEach(() => { - vm.close(); + it('renders modal', () => { + expect(findGlModal().props()).toMatchObject({ + actionCancel: { + attributes: [{ variant: 'default' }], + text: 'Cancel', + }, + actionPrimary: { + attributes: [{ variant: 'confirm' }], + text: 'Create file', + }, + actionSecondary: null, + size: 'lg', + modalId: 'ide-new-entry', + title: 'Create new file', + }); }); - it(`sets modal title as ${entryType}`, () => { - expect(document.querySelector('.modal-title').textContent.trim()).toBe(modalTitle); + it('renders name label', () => { + expect(wrapper.find('label').text()).toBe('Name'); }); - it(`sets button label as ${entryType}`, () => { - expect(document.querySelector('.btn-confirm').textContent.trim()).toBe(btnTitle); + it('renders template buttons', () => { + const actual = findTemplateButtonsModel(); + + expect(actual.length).toBeGreaterThan(0); + expect(actual).toEqual( + store.getters['fileTemplates/templateTypes'].map((template) => ({ + category: 'secondary', + text: template.name, + variant: 'dashed', + })), + ); }); - it(`sets form label as ${entryType}`, () => { - expect(document.querySelector('.label-bold').textContent.trim()).toBe('Name'); + // These negative ".not.toHaveBeenCalled" assertions complement the positive "toHaveBeenCalled" + // assertions that show up later in this spec. Without these, we're not guaranteed the "act" + // actually caused the change in behavior. + it('does not dispatch actions by default', () => { + expect(store.dispatch).not.toHaveBeenCalled(); }); - it(`shows file templates: ${showsFileTemplates}`, () => { - const templateFilesEl = document.querySelector('.file-templates'); - expect(Boolean(templateFilesEl)).toBe(showsFileTemplates); + it('does not trigger modal by default', () => { + expect(showModal).not.toHaveBeenCalled(); + expect(toggleModal).not.toHaveBeenCalled(); }); - }); - describe('rename entry', () => { - beforeEach(() => { - const store = createStore(); - store.state.entries = { - 'test-path': { - name: 'test', - type: 'blob', - path: 'test-path', - }, - }; - - vm = createComponentWithStore(Component, store).$mount(); + it('does not focus input by default', () => { + expect(document.activeElement).toBe(document.body); }); + }); - it.each` - entryType | modalTitle | btnTitle - ${'tree'} | ${'Rename folder'} | ${'Rename folder'} - ${'blob'} | ${'Rename file'} | ${'Rename file'} - `( - 'renders title and button for renaming $entryType', - async ({ entryType, modalTitle, btnTitle }) => { - vm.$store.state.entries['test-path'].type = entryType; - vm.open('rename', 'test-path'); + describe.each` + entryType | path | modalTitle | btnTitle | showsFileTemplates | inputValue | inputPlaceholder + ${'tree'} | ${''} | ${'Create new directory'} | ${'Create directory'} | ${false} | ${''} | ${'dir/'} + ${'blob'} | ${''} | ${'Create new file'} | ${'Create file'} | ${true} | ${''} | ${'dir/file_name'} + ${'blob'} | ${'foo/bar'} | ${'Create new file'} | ${'Create file'} | ${true} | ${'foo/bar/'} | ${'dir/file_name'} + `( + 'when opened as $entryType with path "$path"', + ({ + entryType, + path, + modalTitle, + btnTitle, + showsFileTemplates, + inputValue, + inputPlaceholder, + }) => { + beforeEach(async () => { + mountComponent(); + + open(entryType, path); await nextTick(); - expect(document.querySelector('.modal-title').textContent.trim()).toBe(modalTitle); - expect(document.querySelector('.btn-confirm').textContent.trim()).toBe(btnTitle); - }, - ); + }); - describe('entryName', () => { - it('returns entries name', () => { - vm.open('rename', 'test-path'); + it('sets modal props', () => { + expect(findGlModal().props()).toMatchObject({ + title: modalTitle, + actionPrimary: { + attributes: [{ variant: 'confirm' }], + text: btnTitle, + }, + }); + }); - expect(vm.entryName).toBe('test-path'); + it('sets input attributes', () => { + expect(findInput().element.value).toBe(inputValue); + expect(findInput().attributes('placeholder')).toBe(inputPlaceholder); }); - it('does not reset entryName to its old value if empty', () => { - vm.entryName = 'hello'; - vm.entryName = ''; + it(`shows file templates: ${showsFileTemplates}`, () => { + const actual = findTemplateButtonsModel().length > 0; - expect(vm.entryName).toBe(''); + expect(actual).toBe(showsFileTemplates); + }); + + it('shows modal', () => { + expect(showModal).toHaveBeenCalled(); }); - }); - describe('open', () => { - it('sets entryName to path provided if modalType is rename', () => { - vm.open('rename', 'test-path'); + it('focus on input', () => { + expect(document.activeElement).toBe(findInput().element); + }); + + it('resets when canceled', async () => { + triggerCancel(); + + await nextTick(); - expect(vm.entryName).toBe('test-path'); + // Resets input value + expect(findInput().element.value).toBe(''); + // Resets to blob mode + expect(findGlModal().props('title')).toBe('Create new file'); }); + }, + ); + + describe.each` + modalType | name | expectedName + ${'blob'} | ${'foo/bar.js'} | ${'foo/bar.js'} + ${'blob'} | ${'foo /bar.js'} | ${'foo/bar.js'} + ${'tree'} | ${'foo/dir'} | ${'foo/dir'} + ${'tree'} | ${'foo /dir'} | ${'foo/dir'} + `('when submitting as $modalType with "$name"', ({ modalType, name, expectedName }) => { + describe('when using the modal primary button', () => { + beforeEach(async () => { + mountComponent(); + + open(modalType, ''); + await nextTick(); - it("appends '/' to the path if modalType isn't rename", () => { - vm.open('blob', 'test-path'); + findInput().setValue(name); + triggerSubmitModal(); + }); - expect(vm.entryName).toBe('test-path/'); + it('triggers createTempEntry action', () => { + expect(store.dispatch).toHaveBeenCalledWith('createTempEntry', { + name: expectedName, + type: modalType, + }); }); + }); + + describe('when triggering form submit (pressing enter)', () => { + beforeEach(async () => { + mountComponent(); + + open(modalType, ''); + await nextTick(); - it('leaves entryName blank if no path is provided', () => { - vm.open('blob'); + findInput().setValue(name); + triggerSubmitForm(); + }); - expect(vm.entryName).toBe(''); + it('triggers createTempEntry action', () => { + expect(store.dispatch).toHaveBeenCalledWith('createTempEntry', { + name: expectedName, + type: modalType, + }); }); }); }); - describe('createFromTemplate', () => { - let store; + describe('when creating from template type', () => { + beforeEach(async () => { + mountComponent(); - beforeEach(() => { - store = createStore(); - store.state.entries = { - 'test-path/test': { - name: 'test', - deleted: false, - }, - }; + open('blob', 'some_dir'); - vm = createComponentWithStore(Component, store).$mount(); - vm.open('blob'); + await nextTick(); - jest.spyOn(vm, 'createTempEntry').mockImplementation(); + // Set input, then trigger button + findInput().setValue('some_dir/foo.js'); + findTemplateButtons().at(1).vm.$emit('click'); }); - it.each` - entryName | newFilePath - ${''} | ${'.gitignore'} - ${'README.md'} | ${'.gitignore'} - ${'test-path/test/'} | ${'test-path/test/.gitignore'} - ${'test-path/test'} | ${'test-path/.gitignore'} - ${'test-path/test/abc.md'} | ${'test-path/test/.gitignore'} - `( - 'creates a new file with the given template name in appropriate directory for path: $path', - ({ entryName, newFilePath }) => { - vm.entryName = entryName; + it('triggers createTempEntry action', () => { + const { name: expectedName } = store.getters['fileTemplates/templateTypes'][1]; - vm.createFromTemplate({ name: '.gitignore' }); + expect(store.dispatch).toHaveBeenCalledWith('createTempEntry', { + name: `some_dir/${expectedName}`, + type: 'blob', + }); + }); - expect(vm.createTempEntry).toHaveBeenCalledWith({ - name: newFilePath, - type: 'blob', - }); - }, - ); + it('toggles modal', () => { + expect(toggleModal).toHaveBeenCalled(); + }); }); - describe('submitForm', () => { - let store; + describe.each` + origPath | title | inputValue | inputSelectionStart + ${'src/parent_dir'} | ${'Rename folder'} | ${'src/parent_dir'} | ${'src/'.length} + ${'README.md'} | ${'Rename file'} | ${'README.md'} | ${0} + `('when renaming for $origPath', ({ origPath, title, inputValue, inputSelectionStart }) => { + beforeEach(async () => { + mountComponent(); + + open('rename', origPath); + + await nextTick(); + }); - beforeEach(() => { - store = createStore(); - store.state.entries = { - 'test-path/test': { - name: 'test', - deleted: false, + it('sets modal props for renaming', () => { + expect(findGlModal().props()).toMatchObject({ + title, + actionPrimary: { + attributes: [{ variant: 'confirm' }], + text: title, }, - }; + }); + }); + + it('sets input value', () => { + expect(findInput().element.value).toBe(inputValue); + }); - vm = createComponentWithStore(Component, store).$mount(); + it(`does not show file templates`, () => { + expect(findTemplateButtonsModel()).toHaveLength(0); }); - it('throws an error when target entry exists', () => { - vm.open('rename', 'test-path/test'); + it('shows modal when renaming', () => { + expect(showModal).toHaveBeenCalled(); + }); - expect(createFlash).not.toHaveBeenCalled(); + it('focus on input when renaming', () => { + expect(document.activeElement).toBe(findInput().element); + }); + + it('selects name part of the input', () => { + expect(findInput().element.selectionStart).toBe(inputSelectionStart); + expect(findInput().element.selectionEnd).toBe(origPath.length); + }); + + describe('when renames is submitted successfully', () => { + describe('when using the modal primary button', () => { + beforeEach(() => { + findInput().setValue(NEW_NAME); + triggerSubmitModal(); + }); + + it('dispatches renameEntry event', () => { + expect(store.dispatch).toHaveBeenCalledWith('renameEntry', { + path: origPath, + parentPath: '', + name: NEW_NAME, + }); + }); + + it('does not trigger flash', () => { + expect(createFlash).not.toHaveBeenCalled(); + }); + }); - vm.submitForm(); + describe('when triggering form submit (pressing enter)', () => { + beforeEach(() => { + findInput().setValue(NEW_NAME); + triggerSubmitForm(); + }); + it('dispatches renameEntry event', () => { + expect(store.dispatch).toHaveBeenCalledWith('renameEntry', { + path: origPath, + parentPath: '', + name: NEW_NAME, + }); + }); + + it('does not trigger flash', () => { + expect(createFlash).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('when renaming and file already exists', () => { + beforeEach(async () => { + mountComponent(); + + open('rename', 'src/parent_dir'); + + await nextTick(); + + // Set to something that already exists! + findInput().setValue('src'); + triggerSubmitModal(); + }); + + it('creates flash', () => { expect(createFlash).toHaveBeenCalledWith({ - message: 'The name "test-path/test" is already taken in this directory.', + message: 'The name "src" is already taken in this directory.', fadeTransition: false, addBodyClass: true, }); }); - it('does not throw error when target entry does not exist', () => { - jest.spyOn(vm, 'renameEntry').mockImplementation(); + it('does not dispatch event', () => { + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); - vm.open('rename', 'test-path/test'); - vm.entryName = 'test-path/test2'; - vm.submitForm(); + describe('when renaming and file has been deleted', () => { + beforeEach(async () => { + mountComponent(); - expect(createFlash).not.toHaveBeenCalled(); - }); + open('rename', 'src/parent_dir/foo.js'); - it('removes leading/trailing found in the new name', () => { - vm.open('rename', 'test-path/test'); + await nextTick(); - vm.entryName = 'test-path /test'; + findInput().setValue('src/deleted.js'); + triggerSubmitModal(); + }); - vm.submitForm(); + it('does not create flash', () => { + expect(createFlash).not.toHaveBeenCalled(); + }); - expect(vm.entryName).toBe('test-path/test'); + it('dispatches event', () => { + expect(store.dispatch).toHaveBeenCalledWith('renameEntry', { + path: 'src/parent_dir/foo.js', + name: 'deleted.js', + parentPath: 'src', + }); }); }); }); diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index b44651481e9..7a0bcda1b7a 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -1,3 +1,4 @@ +import { GlTab } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import { editor as monacoEditor, Range } from 'monaco-editor'; import Vue, { nextTick } from 'vue'; @@ -5,6 +6,7 @@ import Vuex from 'vuex'; import { shallowMount } from '@vue/test-utils'; import '~/behaviors/markdown/render_gfm'; import waitForPromises from 'helpers/wait_for_promises'; +import { stubPerformanceWebAPI } from 'helpers/performance'; 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'; @@ -125,10 +127,12 @@ describe('RepoEditor', () => { }; const findEditor = () => wrapper.find('[data-testid="editor-container"]'); - const findTabs = () => wrapper.findAll('.ide-mode-tabs .nav-links li'); + const findTabs = () => wrapper.findAllComponents(GlTab); const findPreviewTab = () => wrapper.find('[data-testid="preview-tab"]'); beforeEach(() => { + stubPerformanceWebAPI(); + createInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_CODE_INSTANCE_FN); createDiffInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_DIFF_INSTANCE_FN); createModelSpy = jest.spyOn(monacoEditor, 'createModel'); @@ -201,12 +205,12 @@ describe('RepoEditor', () => { const tabs = findTabs(); expect(tabs).toHaveLength(2); - expect(tabs.at(0).text()).toBe('Edit'); - expect(tabs.at(1).text()).toBe('Preview Markdown'); + expect(tabs.at(0).element.dataset.testid).toBe('edit-tab'); + expect(tabs.at(1).element.dataset.testid).toBe('preview-tab'); }); it('renders markdown for tempFile', async () => { - findPreviewTab().trigger('click'); + findPreviewTab().vm.$emit('click'); await waitForPromises(); expect(wrapper.find(ContentViewer).html()).toContain(dummyFile.text.content); }); diff --git a/spec/frontend/ide/ide_router_spec.js b/spec/frontend/ide/ide_router_spec.js index cd10812f8ea..adbdba1b11e 100644 --- a/spec/frontend/ide/ide_router_spec.js +++ b/spec/frontend/ide/ide_router_spec.js @@ -1,4 +1,5 @@ import waitForPromises from 'helpers/wait_for_promises'; +import { stubPerformanceWebAPI } from 'helpers/performance'; import { createRouter } from '~/ide/ide_router'; import { createStore } from '~/ide/stores'; @@ -12,6 +13,8 @@ describe('IDE router', () => { let router; beforeEach(() => { + stubPerformanceWebAPI(); + window.history.replaceState({}, '', '/'); store = createStore(); router = createRouter(store, DEFAULT_BRANCH); diff --git a/spec/frontend/ide/stores/actions/file_spec.js b/spec/frontend/ide/stores/actions/file_spec.js index 45d1beea3f8..6c1dee1e5ca 100644 --- a/spec/frontend/ide/stores/actions/file_spec.js +++ b/spec/frontend/ide/stores/actions/file_spec.js @@ -7,6 +7,7 @@ import { createStore } from '~/ide/stores'; import * as actions from '~/ide/stores/actions/file'; import * as types from '~/ide/stores/mutation_types'; import axios from '~/lib/utils/axios_utils'; +import { stubPerformanceWebAPI } from 'helpers/performance'; import { file, createTriggerRenameAction, createTriggerUpdatePayload } from '../../helpers'; const ORIGINAL_CONTENT = 'original content'; @@ -19,6 +20,8 @@ describe('IDE store file actions', () => { let router; beforeEach(() => { + stubPerformanceWebAPI(); + mock = new MockAdapter(axios); originalGon = window.gon; window.gon = { diff --git a/spec/frontend/ide/stores/actions/merge_request_spec.js b/spec/frontend/ide/stores/actions/merge_request_spec.js index 5592e2664c4..abc3ba5b0a2 100644 --- a/spec/frontend/ide/stores/actions/merge_request_spec.js +++ b/spec/frontend/ide/stores/actions/merge_request_spec.js @@ -1,5 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import { range } from 'lodash'; +import { stubPerformanceWebAPI } from 'helpers/performance'; import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; import createFlash from '~/flash'; @@ -35,6 +36,8 @@ describe('IDE store merge request actions', () => { let mock; beforeEach(() => { + stubPerformanceWebAPI(); + store = createStore(); mock = new MockAdapter(axios); diff --git a/spec/frontend/ide/stores/actions/tree_spec.js b/spec/frontend/ide/stores/actions/tree_spec.js index fc44cbb21ae..d43393875eb 100644 --- a/spec/frontend/ide/stores/actions/tree_spec.js +++ b/spec/frontend/ide/stores/actions/tree_spec.js @@ -1,4 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; +import { stubPerformanceWebAPI } from 'helpers/performance'; import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; import { createRouter } from '~/ide/ide_router'; @@ -24,6 +25,8 @@ describe('Multi-file store tree actions', () => { }; beforeEach(() => { + stubPerformanceWebAPI(); + store = createStore(); router = createRouter(store); jest.spyOn(router, 'push').mockImplementation(); diff --git a/spec/frontend/ide/stores/actions_spec.js b/spec/frontend/ide/stores/actions_spec.js index 3889c4f11c3..f6d54491d77 100644 --- a/spec/frontend/ide/stores/actions_spec.js +++ b/spec/frontend/ide/stores/actions_spec.js @@ -1,4 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; +import { stubPerformanceWebAPI } from 'helpers/performance'; import testAction from 'helpers/vuex_action_helper'; import eventHub from '~/ide/eventhub'; import { createRouter } from '~/ide/ide_router'; @@ -34,6 +35,8 @@ describe('Multi-file store actions', () => { let router; beforeEach(() => { + stubPerformanceWebAPI(); + store = createStore(); router = createRouter(store); diff --git a/spec/frontend/import_entities/import_groups/components/import_table_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_spec.js index 0279ad454d2..cdc508a0033 100644 --- a/spec/frontend/import_entities/import_groups/components/import_table_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_table_spec.js @@ -50,13 +50,13 @@ describe('import table', () => { const findPaginationDropdownText = () => findPaginationDropdown().find('button').text(); const findSelectionCount = () => wrapper.find('[data-test-id="selection-count"]'); - const triggerSelectAllCheckbox = () => - wrapper.find('thead input[type=checkbox]').trigger('click'); + const triggerSelectAllCheckbox = (checked = true) => + wrapper.find('thead input[type=checkbox]').setChecked(checked); const selectRow = (idx) => - wrapper.findAll('tbody td input[type=checkbox]').at(idx).trigger('click'); + wrapper.findAll('tbody td input[type=checkbox]').at(idx).setChecked(true); - const createComponent = ({ bulkImportSourceGroups, importGroups }) => { + const createComponent = ({ bulkImportSourceGroups, importGroups, defaultTargetNamespace }) => { apolloProvider = createMockApollo([], { Query: { availableNamespaces: () => availableNamespacesFixture, @@ -73,6 +73,7 @@ describe('import table', () => { jobsPath: '/fake_job_path', sourceUrl: SOURCE_URL, historyPath: '/fake_history_path', + defaultTargetNamespace, }, apolloProvider, }); @@ -165,6 +166,27 @@ describe('import table', () => { expect(targetNamespaceDropdownButton.text()).toBe('No parent'); }); + it('respects default namespace if provided', async () => { + const targetNamespace = availableNamespacesFixture[1]; + + createComponent({ + bulkImportSourceGroups: () => ({ + nodes: FAKE_GROUPS, + pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, + }), + defaultTargetNamespace: targetNamespace.id, + }); + + await waitForPromises(); + + const firstRow = wrapper.find('tbody tr'); + const targetNamespaceDropdownButton = findTargetNamespaceDropdown(firstRow).find( + '[aria-haspopup]', + ); + expect(targetNamespaceDropdownButton.text()).toBe(targetNamespace.fullPath); + }); + it('does not render status string when result list is empty', async () => { createComponent({ bulkImportSourceGroups: jest.fn().mockResolvedValue({ @@ -388,7 +410,7 @@ describe('import table', () => { expect(findSelectionCount().text()).toMatchInterpolatedText('0 selected'); await triggerSelectAllCheckbox(); expect(findSelectionCount().text()).toMatchInterpolatedText('2 selected'); - await triggerSelectAllCheckbox(); + await triggerSelectAllCheckbox(false); expect(findSelectionCount().text()).toMatchInterpolatedText('0 selected'); }); diff --git a/spec/frontend/integrations/edit/components/active_checkbox_spec.js b/spec/frontend/integrations/edit/components/active_checkbox_spec.js index 633389578a0..1f7a5f0dbc9 100644 --- a/spec/frontend/integrations/edit/components/active_checkbox_spec.js +++ b/spec/frontend/integrations/edit/components/active_checkbox_spec.js @@ -1,7 +1,6 @@ import { GlFormCheckbox } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue'; import { createStore } from '~/integrations/edit/store'; @@ -74,22 +73,13 @@ describe('ActiveCheckbox', () => { expect(findGlFormCheckbox().vm.$attrs.checked).toBe(true); }); - describe('on checkbox click', () => { - it('switches the form value', async () => { - findInputInCheckbox().trigger('click'); - - await nextTick(); - expect(findGlFormCheckbox().vm.$attrs.checked).toBe(false); - }); - }); - it('emits `toggle-integration-active` event with `true` on mount', () => { expect(wrapper.emitted('toggle-integration-active')[0]).toEqual([true]); }); describe('on checkbox `change` event', () => { - it('emits `toggle-integration-active` event', () => { - findGlFormCheckbox().vm.$emit('change', false); + it('emits `toggle-integration-active` event', async () => { + await findInputInCheckbox().setChecked(false); expect(wrapper.emitted('toggle-integration-active')[1]).toEqual([false]); }); diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index a2bdece821f..21e57a2e33c 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -596,37 +596,42 @@ describe('IntegrationForm', () => { }); 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, + scenario | replyStatus | errorMessage | serviceResponse | expectToast | expectSentry + ${'when "test settings" request fails'} | ${httpStatus.INTERNAL_SERVER_ERROR} | ${undefined} | ${undefined} | ${I18N_DEFAULT_ERROR_MESSAGE} | ${true} + ${'when "test settings" returns an error'} | ${httpStatus.OK} | ${'an error'} | ${undefined} | ${'an error'} | ${false} + ${'when "test settings" returns an error with details'} | ${httpStatus.OK} | ${'an error.'} | ${'extra info'} | ${'an error. extra info'} | ${false} + ${'when "test settings" succeeds'} | ${httpStatus.OK} | ${undefined} | ${undefined} | ${I18N_SUCCESSFUL_CONNECTION_MESSAGE} | ${false} + `( + '$scenario', + ({ replyStatus, errorMessage, serviceResponse, expectToast, expectSentry }) => { + beforeEach(async () => { + mockAxios.onPut(mockTestPath).replyOnce(replyStatus, { + error: Boolean(errorMessage), + message: errorMessage, + service_response: serviceResponse, + }); + + await findTestButton().vm.$emit('click', new Event('click')); + await waitForPromises(); }); - await findTestButton().vm.$emit('click', new Event('click')); - await waitForPromises(); - }); - - it(`calls toast with '${expectToast}'`, () => { - expect(mockToastShow).toHaveBeenCalledWith(expectToast); - }); + 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 `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('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); - }); - }); + it(`${expectSentry ? 'does' : 'does not'} capture exception in Sentry`, () => { + expect(Sentry.captureException).toHaveBeenCalledTimes(expectSentry ? 1 : 0); + }); + }, + ); }); }); diff --git a/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js index 49fbebb9396..6011b3e6edc 100644 --- a/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js +++ b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js @@ -115,9 +115,8 @@ describe('JiraTriggerFields', () => { const checkbox = findIssueTransitionEnabled(); expect(checkbox.element.checked).toBe(false); - checkbox.trigger('click'); + await checkbox.setChecked(true); - await nextTick(); const [radio1, radio2] = findIssueTransitionModeRadios().wrappers; expect(radio1.element.checked).toBe(true); expect(radio2.element.checked).toBe(false); diff --git a/spec/frontend/invite_members/components/import_a_project_modal_spec.js b/spec/frontend/invite_members/components/import_project_members_modal_spec.js index 6db881d5c75..b4d42d90d99 100644 --- a/spec/frontend/invite_members/components/import_a_project_modal_spec.js +++ b/spec/frontend/invite_members/components/import_project_members_modal_spec.js @@ -5,7 +5,7 @@ import { stubComponent } from 'helpers/stub_component'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import * as ProjectsApi from '~/api/projects_api'; -import ImportAProjectModal from '~/invite_members/components/import_a_project_modal.vue'; +import ImportProjectMembersModal from '~/invite_members/components/import_project_members_modal.vue'; import ProjectSelect from '~/invite_members/components/project_select.vue'; import axios from '~/lib/utils/axios_utils'; @@ -20,7 +20,7 @@ const $toast = { }; const createComponent = () => { - wrapper = shallowMountExtended(ImportAProjectModal, { + wrapper = shallowMountExtended(ImportProjectMembersModal, { propsData: { projectId, projectName, @@ -51,12 +51,11 @@ afterEach(() => { mock.restore(); }); -describe('ImportAProjectModal', () => { +describe('ImportProjectMembersModal', () => { + const findGlModal = () => wrapper.findComponent(GlModal); const findIntroText = () => wrapper.find({ ref: 'modalIntro' }).text(); - const findCancelButton = () => wrapper.findByTestId('cancel-button'); - const findImportButton = () => wrapper.findByTestId('import-button'); - const clickImportButton = () => findImportButton().vm.$emit('click'); - const clickCancelButton = () => findCancelButton().vm.$emit('click'); + const clickImportButton = () => findGlModal().vm.$emit('primary', { preventDefault: jest.fn() }); + const closeModal = () => findGlModal().vm.$emit('hidden', { preventDefault: jest.fn() }); const findFormGroup = () => wrapper.findByTestId('form-group'); const formGroupInvalidFeedback = () => findFormGroup().props('invalidFeedback'); const formGroupErrorState = () => findFormGroup().props('state'); @@ -68,37 +67,40 @@ describe('ImportAProjectModal', () => { }); it('renders the modal with the correct title', () => { - expect(wrapper.findComponent(GlModal).props('title')).toBe( - 'Import members from another project', - ); + expect(findGlModal().props('title')).toBe('Import members from another project'); }); it('renders the Cancel button text correctly', () => { - expect(findCancelButton().text()).toBe('Cancel'); + expect(findGlModal().props('actionCancel')).toMatchObject({ + text: 'Cancel', + }); }); it('renders the Import button text correctly', () => { - expect(findImportButton().text()).toBe('Import project members'); + expect(findGlModal().props('actionPrimary')).toMatchObject({ + text: 'Import project members', + attributes: { + variant: 'confirm', + disabled: true, + loading: false, + }, + }); }); it('renders the modal intro text correctly', () => { expect(findIntroText()).toBe("You're importing members to the test name project."); }); - it('renders the Import button modal without isLoading', () => { - expect(findImportButton().props('loading')).toBe(false); - }); - it('sets isLoading to true when the Invite button is clicked', async () => { clickImportButton(); await nextTick(); - expect(findImportButton().props('loading')).toBe(true); + expect(findGlModal().props('actionPrimary').attributes.loading).toBe(true); }); }); - describe('submitting the import form', () => { + describe('submitting the import', () => { describe('when the import is successful', () => { beforeEach(() => { createComponent(); @@ -125,7 +127,7 @@ describe('ImportAProjectModal', () => { }); it('sets isLoading to false after success', () => { - expect(findImportButton().props('loading')).toBe(false); + expect(findGlModal().props('actionPrimary').attributes.loading).toBe(false); }); }); @@ -149,14 +151,14 @@ describe('ImportAProjectModal', () => { }); it('sets isLoading to false after error', () => { - expect(findImportButton().props('loading')).toBe(false); + expect(findGlModal().props('actionPrimary').attributes.loading).toBe(false); }); it('clears the error when the modal is closed with an error', async () => { expect(formGroupInvalidFeedback()).toBe('Unable to import project members'); expect(formGroupErrorState()).toBe(false); - clickCancelButton(); + closeModal(); await nextTick(); diff --git a/spec/frontend/invite_members/components/import_project_members_trigger_spec.js b/spec/frontend/invite_members/components/import_project_members_trigger_spec.js new file mode 100644 index 00000000000..b6375fcfa22 --- /dev/null +++ b/spec/frontend/invite_members/components/import_project_members_trigger_spec.js @@ -0,0 +1,49 @@ +import { GlButton } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import ImportProjectMembersTrigger from '~/invite_members/components/import_project_members_trigger.vue'; +import eventHub from '~/invite_members/event_hub'; + +const displayText = 'Import Project Members'; + +const createComponent = (props = {}) => { + return mount(ImportProjectMembersTrigger, { + propsData: { + displayText, + ...props, + }, + }); +}; + +describe('ImportProjectMembersTrigger', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + }); + + const findButton = () => wrapper.findComponent(GlButton); + + describe('displayText', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('includes the correct displayText for the link', () => { + expect(findButton().text()).toBe(displayText); + }); + }); + + describe('when button is clicked', () => { + beforeEach(() => { + eventHub.$emit = jest.fn(); + + wrapper = createComponent(); + + findButton().trigger('click'); + }); + + it('emits event that triggers opening the modal', () => { + expect(eventHub.$emit).toHaveBeenLastCalledWith('openProjectMembersModal'); + }); + }); +}); 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 13985ce7d74..045a454e63a 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -35,6 +35,7 @@ import { user2, user3, user4, + user5, GlEmoji, } from '../mock_data/member_modal'; @@ -93,6 +94,11 @@ describe('InviteMembersModal', () => { const findModal = () => wrapper.findComponent(GlModal); const findBase = () => wrapper.findComponent(InviteModalBase); const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text(); + const findMemberErrorAlert = () => wrapper.findByTestId('alert-member-error'); + const findMemberErrorMessage = (element) => + `${Object.keys(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[element]}: ${ + Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[element] + }`; const emitEventFromModal = (eventName) => () => findModal().vm.$emit(eventName, { preventDefault: jest.fn() }); const clickInviteButton = emitEventFromModal('primary'); @@ -123,6 +129,10 @@ describe('InviteMembersModal', () => { findBase().vm.$emit('access-level', val); await nextTick(); }; + const removeMembersToken = async (val) => { + findMembersSelect().vm.$emit('token-remove', val); + await nextTick(); + }; describe('rendering the tasks to be done', () => { const setupComponent = async (props = {}, urlParameter = ['invite_members_for_task']) => { @@ -431,17 +441,20 @@ describe('InviteMembersModal', () => { }); it('clears the error when the list of members to invite is cleared', async () => { - expect(membersFormGroupInvalidFeedback()).toBe( + expect(findMemberErrorAlert().exists()).toBe(true); + expect(findMemberErrorAlert().text()).toContain( Object.values(invitationsApiResponse.EMAIL_TAKEN.message)[0], ); - expect(findMembersSelect().props('validationState')).toBe(false); + expect(membersFormGroupInvalidFeedback()).toBe(''); + expect(findMembersSelect().props('exceptionState')).not.toBe(false); findMembersSelect().vm.$emit('clear'); await nextTick(); + expect(findMemberErrorAlert().exists()).toBe(false); expect(membersFormGroupInvalidFeedback()).toBe(''); - expect(findMembersSelect().props('validationState')).not.toBe(false); + expect(findMembersSelect().props('exceptionState')).not.toBe(false); }); it('clears the error when the cancel button is clicked', async () => { @@ -450,7 +463,7 @@ describe('InviteMembersModal', () => { await nextTick(); expect(membersFormGroupInvalidFeedback()).toBe(''); - expect(findMembersSelect().props('validationState')).not.toBe(false); + expect(findMembersSelect().props('exceptionState')).not.toBe(false); }); it('clears the error when the modal is hidden', async () => { @@ -458,33 +471,12 @@ describe('InviteMembersModal', () => { await nextTick(); + expect(findMemberErrorAlert().exists()).toBe(false); expect(membersFormGroupInvalidFeedback()).toBe(''); - expect(findMembersSelect().props('validationState')).not.toBe(false); + expect(findMembersSelect().props('exceptionState')).not.toBe(false); }); }); - it('clears the invalid state and message once the list of members to invite is cleared', async () => { - mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN); - - clickInviteButton(); - - await waitForPromises(); - - expect(membersFormGroupInvalidFeedback()).toBe( - Object.values(invitationsApiResponse.EMAIL_TAKEN.message)[0], - ); - expect(findMembersSelect().props('validationState')).toBe(false); - expect(findModal().props('actionPrimary').attributes.loading).toBe(false); - - findMembersSelect().vm.$emit('clear'); - - await waitForPromises(); - - expect(membersFormGroupInvalidFeedback()).toBe(''); - expect(findMembersSelect().props('validationState')).toBe(null); - expect(findModal().props('actionPrimary').attributes.loading).toBe(false); - }); - it('displays the generic error for http server error', async () => { mockInvitationsApi( httpStatus.INTERNAL_SERVER_ERROR, @@ -496,6 +488,7 @@ describe('InviteMembersModal', () => { await waitForPromises(); expect(membersFormGroupInvalidFeedback()).toBe('Something went wrong'); + expect(findMembersSelect().props('exceptionState')).toBe(false); }); it('displays the restricted user api message for response with bad request', async () => { @@ -505,20 +498,31 @@ describe('InviteMembersModal', () => { await waitForPromises(); - expect(membersFormGroupInvalidFeedback()).toBe(expectedEmailRestrictedError); + expect(findMemberErrorAlert().exists()).toBe(true); + expect(findMemberErrorAlert().text()).toContain(expectedEmailRestrictedError); + expect(membersFormGroupInvalidFeedback()).toBe(''); + expect(findMembersSelect().props('exceptionState')).not.toBe(false); }); - it('displays the first part of the error when multiple existing users are restricted by email', async () => { + it('displays all errors when there are multiple existing users that are restricted by email', async () => { mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED); clickInviteButton(); await waitForPromises(); - expect(membersFormGroupInvalidFeedback()).toBe( - "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.", + expect(findMemberErrorAlert().exists()).toBe(true); + expect(findMemberErrorAlert().text()).toContain( + Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[0], + ); + expect(findMemberErrorAlert().text()).toContain( + Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[1], ); - expect(findMembersSelect().props('validationState')).toBe(false); + expect(findMemberErrorAlert().text()).toContain( + Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[2], + ); + expect(membersFormGroupInvalidFeedback()).toBe(''); + expect(findMembersSelect().props('exceptionState')).not.toBe(false); }); }); }); @@ -573,10 +577,30 @@ describe('InviteMembersModal', () => { await waitForPromises(); expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError); - expect(findMembersSelect().props('validationState')).toBe(false); + expect(findMembersSelect().props('exceptionState')).toBe(false); expect(findModal().props('actionPrimary').attributes.loading).toBe(false); }); + it('clears the error when the modal is hidden', async () => { + mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID); + + clickInviteButton(); + + await waitForPromises(); + + expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError); + expect(findMembersSelect().props('exceptionState')).toBe(false); + expect(findModal().props('actionPrimary').attributes.loading).toBe(false); + + findModal().vm.$emit('hidden'); + + await nextTick(); + + expect(findMemberErrorAlert().exists()).toBe(false); + expect(membersFormGroupInvalidFeedback()).toBe(''); + expect(findMembersSelect().props('exceptionState')).not.toBe(false); + }); + it('displays the restricted email error when restricted email is invited', async () => { mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_RESTRICTED); @@ -584,20 +608,32 @@ describe('InviteMembersModal', () => { await waitForPromises(); - expect(membersFormGroupInvalidFeedback()).toContain(expectedEmailRestrictedError); - expect(findMembersSelect().props('validationState')).toBe(false); + expect(findMemberErrorAlert().exists()).toBe(true); + expect(findMemberErrorAlert().text()).toContain(expectedEmailRestrictedError); + expect(membersFormGroupInvalidFeedback()).toBe(''); + expect(findMembersSelect().props('exceptionState')).not.toBe(false); expect(findModal().props('actionPrimary').attributes.loading).toBe(false); }); - it('displays the first error message when multiple emails return a restricted error message', async () => { + it('displays all errors when there are multiple emails that return a restricted error message', async () => { mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED); clickInviteButton(); await waitForPromises(); - expect(membersFormGroupInvalidFeedback()).toContain(expectedEmailRestrictedError); - expect(findMembersSelect().props('validationState')).toBe(false); + expect(findMemberErrorAlert().exists()).toBe(true); + expect(findMemberErrorAlert().text()).toContain( + Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[0], + ); + expect(findMemberErrorAlert().text()).toContain( + Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[1], + ); + expect(findMemberErrorAlert().text()).toContain( + Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[2], + ); + expect(membersFormGroupInvalidFeedback()).toBe(''); + expect(findMembersSelect().props('exceptionState')).not.toBe(false); }); it('displays the invalid syntax error for bad request', async () => { @@ -608,7 +644,7 @@ describe('InviteMembersModal', () => { await waitForPromises(); expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError); - expect(findMembersSelect().props('validationState')).toBe(false); + expect(findMembersSelect().props('exceptionState')).toBe(false); }); }); @@ -617,14 +653,51 @@ describe('InviteMembersModal', () => { createInviteMembersToGroupWrapper(); await triggerMembersTokenSelect([user3, user4]); - mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.ERROR_EMAIL_INVALID); + mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.ERROR_EMAIL_INVALID); clickInviteButton(); await waitForPromises(); expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError); - expect(findMembersSelect().props('validationState')).toBe(false); + expect(findMembersSelect().props('exceptionState')).toBe(false); + }); + + it('displays errors for multiple and allows clearing', async () => { + createInviteMembersToGroupWrapper(); + + await triggerMembersTokenSelect([user3, user4, user5]); + mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED); + + clickInviteButton(); + + await waitForPromises(); + + expect(findMemberErrorAlert().exists()).toBe(true); + expect(findMemberErrorAlert().props('title')).toContain( + "The following 3 members couldn't be invited", + ); + expect(findMemberErrorAlert().text()).toContain(findMemberErrorMessage(0)); + expect(findMemberErrorAlert().text()).toContain(findMemberErrorMessage(1)); + expect(findMemberErrorAlert().text()).toContain(findMemberErrorMessage(2)); + + await removeMembersToken(user3); + + expect(findMemberErrorAlert().props('title')).toContain( + "The following 2 members couldn't be invited", + ); + expect(findMemberErrorAlert().text()).not.toContain(findMemberErrorMessage(0)); + + await removeMembersToken(user4); + + expect(findMemberErrorAlert().props('title')).toContain( + "The following member couldn't be invited", + ); + expect(findMemberErrorAlert().text()).not.toContain(findMemberErrorMessage(1)); + + await removeMembersToken(user5); + + expect(findMemberErrorAlert().exists()).toBe(false); }); }); }); @@ -675,24 +748,6 @@ describe('InviteMembersModal', () => { }); }); }); - - describe('when any invite failed for any reason', () => { - beforeEach(async () => { - createInviteMembersToGroupWrapper(); - - await triggerMembersTokenSelect([user1, user3]); - - mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID); - - clickInviteButton(); - }); - - it('displays the first error message', async () => { - await waitForPromises(); - - expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError); - }); - }); }); describe('tracking', () => { diff --git a/spec/frontend/invite_members/components/invite_modal_base_spec.js b/spec/frontend/invite_members/components/invite_modal_base_spec.js index cc19e90a5fa..b55eeb72471 100644 --- a/spec/frontend/invite_members/components/invite_modal_base_spec.js +++ b/spec/frontend/invite_members/components/invite_modal_base_spec.js @@ -254,7 +254,7 @@ describe('InviteModalBase', () => { expect(wrapper.findComponent(GlModal).props('actionPrimary').attributes.loading).toBe(true); }); - it('with invalidFeedbackMessage, set members form group validation state', () => { + it('with invalidFeedbackMessage, set members form group exception state', () => { createComponent({ invalidFeedbackMessage: 'invalid message!', }); diff --git a/spec/frontend/invite_members/components/members_token_select_spec.js b/spec/frontend/invite_members/components/members_token_select_spec.js index bf5564e4d63..6375d0f7e2e 100644 --- a/spec/frontend/invite_members/components/members_token_select_spec.js +++ b/spec/frontend/invite_members/components/members_token_select_spec.js @@ -16,6 +16,7 @@ const createComponent = (props) => { return shallowMount(MembersTokenSelect, { propsData: { ariaLabelledby: label, + invalidMembers: {}, placeholder, ...props, }, @@ -124,12 +125,14 @@ describe('MembersTokenSelect', () => { findTokenSelector().vm.$emit('token-remove', [user1]); expect(wrapper.emitted('clear')).toEqual([[]]); + expect(wrapper.emitted('token-remove')).toBeUndefined(); }); - it('does not emit `clear` event when there are still tokens selected', () => { + it('emits `token-remove` event with the token when there are still tokens selected', () => { findTokenSelector().vm.$emit('input', [user1, user2]); findTokenSelector().vm.$emit('token-remove', [user1]); + expect(wrapper.emitted('token-remove')).toEqual([[[user1]]]); expect(wrapper.emitted('clear')).toBeUndefined(); }); }); diff --git a/spec/frontend/invite_members/mock_data/member_modal.js b/spec/frontend/invite_members/mock_data/member_modal.js index 474234cfacb..7d675b6206c 100644 --- a/spec/frontend/invite_members/mock_data/member_modal.js +++ b/spec/frontend/invite_members/mock_data/member_modal.js @@ -26,13 +26,17 @@ export const user2 = { id: 2, name: 'Name Two', username: 'one_2', avatar_url: ' export const user3 = { id: 'user-defined-token', name: 'email@example.com', - username: 'one_2', avatar_url: '', }; export const user4 = { - id: 'user-defined-token', + id: 'user-defined-token2', name: 'email4@example.com', - username: 'one_4', + avatar_url: '', +}; +export const user5 = { + id: '3', + username: 'root', + name: 'root', avatar_url: '', }; diff --git a/spec/frontend/invite_members/utils/member_utils_spec.js b/spec/frontend/invite_members/utils/member_utils_spec.js new file mode 100644 index 00000000000..eb76c9845d4 --- /dev/null +++ b/spec/frontend/invite_members/utils/member_utils_spec.js @@ -0,0 +1,12 @@ +import { memberName } from '~/invite_members/utils/member_utils'; + +describe('Member Name', () => { + it.each([ + [{ username: '_username_', name: '_name_' }, '_username_'], + [{ username: '_username_' }, '_username_'], + [{ name: '_name_' }, '_name_'], + [{}, undefined], + ])(`returns name from supplied member token: %j`, (member, result) => { + expect(memberName(member)).toBe(result); + }); +}); diff --git a/spec/frontend/invite_members/utils/response_message_parser_spec.js b/spec/frontend/invite_members/utils/response_message_parser_spec.js index 8b2064df374..92f38c54c99 100644 --- a/spec/frontend/invite_members/utils/response_message_parser_spec.js +++ b/spec/frontend/invite_members/utils/response_message_parser_spec.js @@ -1,5 +1,5 @@ import { - responseMessageFromSuccess, + responseFromSuccess, responseMessageFromError, } from '~/invite_members/utils/response_message_parser'; import { invitationsApiResponse } from '../mock_data/api_responses'; @@ -11,12 +11,12 @@ describe('Response message parser', () => { const exampleKeyedMsg = { 'email@example.com': expectedMessage }; it.each([ - [{ data: { message: expectedMessage } }], - [{ data: { error: expectedMessage } }], - [{ data: { message: [expectedMessage] } }], - [{ data: { message: exampleKeyedMsg } }], - ])(`returns "${expectedMessage}" from success response: %j`, (successResponse) => { - expect(responseMessageFromSuccess(successResponse)).toBe(expectedMessage); + [{ data: { message: expectedMessage } }, { error: true, message: expectedMessage }], + [{ data: { error: expectedMessage } }, { error: true, message: expectedMessage }], + [{ data: { message: [expectedMessage] } }, { error: true, message: expectedMessage }], + [{ data: { message: exampleKeyedMsg } }, { error: true, message: { ...exampleKeyedMsg } }], + ])(`returns "${expectedMessage}" from success response: %j`, (successResponse, result) => { + expect(responseFromSuccess(successResponse)).toStrictEqual(result); }); }); @@ -30,15 +30,18 @@ describe('Response message parser', () => { }); }); - describe('displaying only the first error when a response has messages for multiple users', () => { - const expected = - "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups."; - + describe('displaying all errors when a response has messages for multiple users', () => { it.each([ - [{ data: invitationsApiResponse.MULTIPLE_RESTRICTED }], - [{ data: invitationsApiResponse.EMAIL_RESTRICTED }], - ])(`returns "${expectedMessage}" from success response: %j`, (restrictedResponse) => { - expect(responseMessageFromSuccess(restrictedResponse)).toBe(expected); + [ + { data: invitationsApiResponse.MULTIPLE_RESTRICTED }, + { error: true, message: { ...invitationsApiResponse.MULTIPLE_RESTRICTED.message } }, + ], + [ + { data: invitationsApiResponse.EMAIL_RESTRICTED }, + { error: true, message: { ...invitationsApiResponse.EMAIL_RESTRICTED.message } }, + ], + ])(`returns "${expectedMessage}" from success response: %j`, (restrictedResponse, result) => { + expect(responseFromSuccess(restrictedResponse)).toStrictEqual(result); }); }); }); diff --git a/spec/frontend/issuable/issuable_form_spec.js b/spec/frontend/issuable/issuable_form_spec.js index a1583076b41..d844f3394d5 100644 --- a/spec/frontend/issuable/issuable_form_spec.js +++ b/spec/frontend/issuable/issuable_form_spec.js @@ -47,6 +47,25 @@ describe('IssuableForm', () => { }); }); + describe('resetAutosave', () => { + it('resets autosave on elements with the .js-reset-autosave class', () => { + setHTMLFixture(` + <form> + <input name="[title]" /> + <textarea name="[description]"></textarea> + <a class="js-reset-autosave">Cancel</a> + </form> + `); + const $form = $('form'); + const resetAutosave = jest.spyOn(IssuableForm.prototype, 'resetAutosave'); + createIssuable($form); + + $form.find('.js-reset-autosave').trigger('click'); + + expect(resetAutosave).toHaveBeenCalled(); + }); + }); + describe('removeWip', () => { it.each` prefix 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 3f2c3c3ec5f..3d3dbfa6853 100644 --- a/spec/frontend/issues/list/components/issues_list_app_spec.js +++ b/spec/frontend/issues/list/components/issues_list_app_spec.js @@ -29,6 +29,7 @@ import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_ro 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 { CREATED_DESC, RELATIVE_POSITION, @@ -98,6 +99,7 @@ describe('CE IssuesListApp component', () => { }; let defaultQueryResponse = getIssuesQueryResponse; + let router; if (IS_EE) { defaultQueryResponse = cloneDeep(getIssuesQueryResponse); defaultQueryResponse.data.project.issues.nodes[0].blockingCount = 1; @@ -133,9 +135,11 @@ describe('CE IssuesListApp component', () => { [setSortPreferenceMutation, sortPreferenceMutationResponse], ]; + router = new VueRouter({ mode: 'history' }); + return mountFn(IssuesListApp, { apolloProvider: createMockApollo(requestHandlers), - router: new VueRouter({ mode: 'history' }), + router, provide: { ...defaultProvide, ...provide, @@ -736,7 +740,7 @@ describe('CE IssuesListApp component', () => { describe('when "click-tab" event is emitted by IssuableList', () => { beforeEach(() => { wrapper = mountComponent(); - jest.spyOn(wrapper.vm.$router, 'push'); + router.push = jest.fn(); findIssuableList().vm.$emit('click-tab', IssuableStates.Closed); }); @@ -746,16 +750,26 @@ describe('CE IssuesListApp component', () => { }); it('updates url to the new tab', () => { - expect(wrapper.vm.$router.push).toHaveBeenCalledWith({ + expect(router.push).toHaveBeenCalledWith({ query: expect.objectContaining({ state: IssuableStates.Closed }), }); }); }); describe.each` - event | params - ${'next-page'} | ${{ page_after: 'endCursor', page_before: undefined, first_page_size: 20, last_page_size: undefined }} - ${'previous-page'} | ${{ page_after: undefined, page_before: 'startCursor', first_page_size: undefined, last_page_size: 20 }} + event | params + ${'next-page'} | ${{ + page_after: 'endCursor', + page_before: undefined, + first_page_size: 20, + last_page_size: undefined, +}} + ${'previous-page'} | ${{ + page_after: undefined, + page_before: 'startCursor', + first_page_size: undefined, + last_page_size: 20, +}} `('when "$event" event is emitted by IssuableList', ({ event, params }) => { beforeEach(() => { wrapper = mountComponent({ @@ -766,7 +780,7 @@ describe('CE IssuesListApp component', () => { }, }, }); - jest.spyOn(wrapper.vm.$router, 'push'); + router.push = jest.fn(); findIssuableList().vm.$emit(event); }); @@ -776,7 +790,7 @@ describe('CE IssuesListApp component', () => { }); it(`updates url`, () => { - expect(wrapper.vm.$router.push).toHaveBeenCalledWith({ + expect(router.push).toHaveBeenCalledWith({ query: expect.objectContaining(params), }); }); @@ -888,13 +902,13 @@ describe('CE IssuesListApp component', () => { 'updates to the new sort when payload is `%s`', async (sortKey) => { wrapper = mountComponent(); - jest.spyOn(wrapper.vm.$router, 'push'); + router.push = jest.fn(); findIssuableList().vm.$emit('sort', sortKey); jest.runOnlyPendingTimers(); await nextTick(); - expect(wrapper.vm.$router.push).toHaveBeenCalledWith({ + expect(router.push).toHaveBeenCalledWith({ query: expect.objectContaining({ sort: urlSortParams[sortKey] }), }); }, @@ -907,13 +921,13 @@ describe('CE IssuesListApp component', () => { wrapper = mountComponent({ provide: { initialSort, isIssueRepositioningDisabled: true }, }); - jest.spyOn(wrapper.vm.$router, 'push'); + router.push = jest.fn(); findIssuableList().vm.$emit('sort', RELATIVE_POSITION_ASC); }); it('does not update the sort to manual', () => { - expect(wrapper.vm.$router.push).not.toHaveBeenCalled(); + expect(router.push).not.toHaveBeenCalled(); }); it('shows an alert to tell the user that manual reordering is disabled', () => { @@ -978,12 +992,12 @@ describe('CE IssuesListApp component', () => { describe('when "filter" event is emitted by IssuableList', () => { it('updates IssuableList with url params', async () => { wrapper = mountComponent(); - jest.spyOn(wrapper.vm.$router, 'push'); + router.push = jest.fn(); findIssuableList().vm.$emit('filter', filteredTokens); await nextTick(); - expect(wrapper.vm.$router.push).toHaveBeenCalledWith({ + expect(router.push).toHaveBeenCalledWith({ query: expect.objectContaining(urlParams), }); }); @@ -993,13 +1007,13 @@ describe('CE IssuesListApp component', () => { wrapper = mountComponent({ provide: { isAnonymousSearchDisabled: true, isSignedIn: false }, }); - jest.spyOn(wrapper.vm.$router, 'push'); + router.push = jest.fn(); findIssuableList().vm.$emit('filter', filteredTokens); }); it('does not update url params', () => { - expect(wrapper.vm.$router.push).not.toHaveBeenCalled(); + expect(router.push).not.toHaveBeenCalled(); }); it('shows an alert to tell the user they must be signed in to search', () => { @@ -1030,4 +1044,19 @@ describe('CE IssuesListApp component', () => { expect(mockQuery).toHaveBeenCalledWith(expect.objectContaining({ hideUsers })); }); }); + + describe('when "page-size-change" event is emitted by IssuableList', () => { + it('updates url params with new page size', async () => { + wrapper = mountComponent(); + router.push = jest.fn(); + + findIssuableList().vm.$emit('page-size-change', 50); + await nextTick(); + + expect(router.push).toHaveBeenCalledTimes(1); + expect(router.push).toHaveBeenCalledWith({ + query: expect.objectContaining({ first_page_size: 50 }), + }); + }); + }); }); diff --git a/spec/frontend/issues/list/mock_data.js b/spec/frontend/issues/list/mock_data.js index 42f2d08082e..4347c580a4d 100644 --- a/spec/frontend/issues/list/mock_data.js +++ b/spec/frontend/issues/list/mock_data.js @@ -32,6 +32,7 @@ export const getIssuesQueryResponse = { state: 'opened', title: 'Issue title', updatedAt: '2021-05-22T04:08:01Z', + closedAt: null, upvotes: 3, userDiscussionsCount: 4, webPath: 'project/-/issues/789', diff --git a/spec/frontend/issues/list/utils_spec.js b/spec/frontend/issues/list/utils_spec.js index e8ffba9bc80..3c6332d5728 100644 --- a/spec/frontend/issues/list/utils_spec.js +++ b/spec/frontend/issues/list/utils_spec.js @@ -10,12 +10,7 @@ import { urlParams, urlParamsWithSpecialValues, } from 'jest/issues/list/mock_data'; -import { - PAGE_SIZE, - PAGE_SIZE_MANUAL, - RELATIVE_POSITION_ASC, - urlSortParams, -} from '~/issues/list/constants'; +import { PAGE_SIZE, urlSortParams } from '~/issues/list/constants'; import { convertToApiParams, convertToSearchQuery, @@ -29,52 +24,30 @@ import { import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; describe('getInitialPageParams', () => { - it.each(Object.keys(urlSortParams))( - 'returns the correct page params for sort key %s', - (sortKey) => { - const firstPageSize = sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE; + it('returns page params with a default page size when no arguments are given', () => { + expect(getInitialPageParams()).toEqual({ firstPageSize: PAGE_SIZE }); + }); - expect(getInitialPageParams(sortKey)).toEqual({ firstPageSize }); - }, - ); + it('returns page params with the given page size', () => { + const pageSize = 100; + expect(getInitialPageParams(pageSize)).toEqual({ firstPageSize: pageSize }); + }); - it.each(Object.keys(urlSortParams))( - 'returns the correct page params for sort key %s with afterCursor', - (sortKey) => { - const firstPageSize = sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE; - const lastPageSize = undefined; - const afterCursor = 'randomCursorString'; - const beforeCursor = undefined; - const pageParams = getInitialPageParams( - sortKey, - firstPageSize, - lastPageSize, - afterCursor, - beforeCursor, - ); - - expect(pageParams).toEqual({ firstPageSize, afterCursor }); - }, - ); + it('does not return firstPageSize when lastPageSize is provided', () => { + const firstPageSize = 100; + const lastPageSize = 50; + const afterCursor = undefined; + const beforeCursor = 'randomCursorString'; + const pageParams = getInitialPageParams( + 100, + firstPageSize, + lastPageSize, + afterCursor, + beforeCursor, + ); - it.each(Object.keys(urlSortParams))( - 'returns the correct page params for sort key %s with beforeCursor', - (sortKey) => { - const firstPageSize = undefined; - const lastPageSize = PAGE_SIZE; - const afterCursor = undefined; - const beforeCursor = 'anotherRandomCursorString'; - const pageParams = getInitialPageParams( - sortKey, - firstPageSize, - lastPageSize, - afterCursor, - beforeCursor, - ); - - expect(pageParams).toEqual({ lastPageSize, beforeCursor }); - }, - ); + expect(pageParams).toEqual({ lastPageSize, beforeCursor }); + }); }); describe('getSortKey', () => { @@ -97,10 +70,10 @@ describe('isSortKey', () => { describe('getSortOptions', () => { describe.each` hasIssueWeightsFeature | hasBlockedIssuesFeature | length | containsWeight | containsBlocking - ${false} | ${false} | ${9} | ${false} | ${false} - ${true} | ${false} | ${10} | ${true} | ${false} - ${false} | ${true} | ${10} | ${false} | ${true} - ${true} | ${true} | ${11} | ${true} | ${true} + ${false} | ${false} | ${10} | ${false} | ${false} + ${true} | ${false} | ${11} | ${true} | ${false} + ${false} | ${true} | ${11} | ${false} | ${true} + ${true} | ${true} | ${12} | ${true} | ${true} `( 'when hasIssueWeightsFeature=$hasIssueWeightsFeature and hasBlockedIssuesFeature=$hasBlockedIssuesFeature', ({ diff --git a/spec/frontend/issues/new/components/__snapshots__/type_popover_spec.js.snap b/spec/frontend/issues/new/components/__snapshots__/type_popover_spec.js.snap index 881dcda126f..1a199ed2ee9 100644 --- a/spec/frontend/issues/new/components/__snapshots__/type_popover_spec.js.snap +++ b/spec/frontend/issues/new/components/__snapshots__/type_popover_spec.js.snap @@ -2,10 +2,11 @@ exports[`Issue type info popover renders 1`] = ` <span + class="gl-ml-2" id="popovercontainer" > <gl-icon-stub - class="gl-ml-5 gl-text-gray-500" + class="gl-text-blue-600" id="issue-type-info" name="question-o" size="16" diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js index 2cc27309e59..8ee57f97754 100644 --- a/spec/frontend/issues/show/components/description_spec.js +++ b/spec/frontend/issues/show/components/description_spec.js @@ -15,10 +15,15 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import Description from '~/issues/show/components/description.vue'; import { updateHistory } from '~/lib/utils/url_utility'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; +import workItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; +import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql'; import TaskList from '~/task_list'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; -import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; +import { + projectWorkItemTypesQueryResponse, + createWorkItemFromTaskMutationResponse, +} from 'jest/work_items/mock_data'; import { descriptionProps as initialProps, descriptionHtmlWithCheckboxes, @@ -46,6 +51,10 @@ const workItemQueryResponse = { }; const queryHandler = jest.fn().mockResolvedValue(workItemQueryResponse); +const workItemTypesQueryHandler = jest.fn().mockResolvedValue(projectWorkItemTypesQueryResponse); +const createWorkItemFromTaskSuccessHandler = jest + .fn() + .mockResolvedValue(createWorkItemFromTaskMutationResponse); describe('Description component', () => { let wrapper; @@ -60,18 +69,24 @@ describe('Description component', () => { const findTooltips = () => wrapper.findAllComponents(GlTooltip); const findModal = () => wrapper.findComponent(GlModal); - const findCreateWorkItem = () => wrapper.findComponent(CreateWorkItem); const findWorkItemDetailModal = () => wrapper.findComponent(WorkItemDetailModal); - function createComponent({ props = {}, provide = {} } = {}) { + function createComponent({ props = {}, provide } = {}) { wrapper = shallowMountExtended(Description, { propsData: { issueId: 1, ...initialProps, ...props, }, - provide, - apolloProvider: createMockApollo([[workItemQuery, queryHandler]]), + provide: { + fullPath: 'gitlab-org/gitlab-test', + ...provide, + }, + apolloProvider: createMockApollo([ + [workItemQuery, queryHandler], + [workItemTypesQuery, workItemTypesQueryHandler], + [createWorkItemFromTaskMutation, createWorkItemFromTaskSuccessHandler], + ]), mocks: { $toast, }, @@ -299,24 +314,16 @@ describe('Description component', () => { }); it('does not show a modal by default', () => { - expect(findModal().props('visible')).toBe(false); + expect(findModal().exists()).toBe(false); }); - it('opens a modal when a button is clicked and displays correct title', async () => { - await findConvertToTaskButton().trigger('click'); - expect(findCreateWorkItem().props('initialTitle').trim()).toBe('todo 1'); - }); + it('emits `updateDescription` after creating new work item', async () => { + const newDescription = `<p>New description</p>`; - it('closes the modal on `closeCreateTaskModal` event', async () => { await findConvertToTaskButton().trigger('click'); - findCreateWorkItem().vm.$emit('closeModal'); - expect(hideModal).toHaveBeenCalled(); - }); - it('emits `updateDescription` on `onCreate` event', () => { - const newDescription = `<p>New description</p>`; - findCreateWorkItem().vm.$emit('onCreate', newDescription); - expect(hideModal).toHaveBeenCalled(); + await waitForPromises(); + expect(wrapper.emitted('updateDescription')).toEqual([[newDescription]]); }); @@ -325,7 +332,7 @@ describe('Description component', () => { findWorkItemDetailModal().vm.$emit('workItemDeleted', newDesc); expect(wrapper.emitted('updateDescription')).toEqual([[newDesc]]); - expect($toast.show).toHaveBeenCalledWith('Work item deleted'); + expect($toast.show).toHaveBeenCalledWith('Task deleted'); }); }); diff --git a/spec/frontend/issues/show/components/edited_spec.js b/spec/frontend/issues/show/components/edited_spec.js index 8a8fe23230a..8a240c38b5f 100644 --- a/spec/frontend/issues/show/components/edited_spec.js +++ b/spec/frontend/issues/show/components/edited_spec.js @@ -1,49 +1,50 @@ -import Vue from 'vue'; -import edited from '~/issues/show/components/edited.vue'; - -function formatText(text) { - return text.trim().replace(/\s\s+/g, ' '); -} - -describe('edited', () => { - const EditedComponent = Vue.extend(edited); - - it('should render an edited at+by string', () => { - const editedComponent = new EditedComponent({ - propsData: { - updatedAt: '2017-05-15T12:31:04.428Z', - updatedByName: 'Some User', - updatedByPath: '/some_user', - }, - }).$mount(); - - expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited[\s\S]+?by Some User/); - expect(editedComponent.$el.querySelector('.author-link').href).toMatch(/\/some_user$/); - expect(editedComponent.$el.querySelector('time')).toBeTruthy(); +import { shallowMount } from '@vue/test-utils'; +import Edited from '~/issues/show/components/edited.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +describe('Edited component', () => { + let wrapper; + + const findAuthorLink = () => wrapper.find('a'); + const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip); + const formatText = (text) => text.trim().replace(/\s\s+/g, ' '); + + const mountComponent = (propsData) => shallowMount(Edited, { propsData }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders an edited at+by string', () => { + wrapper = mountComponent({ + updatedAt: '2017-05-15T12:31:04.428Z', + updatedByName: 'Some User', + updatedByPath: '/some_user', + }); + + expect(formatText(wrapper.text())).toBe('Edited by Some User'); + expect(findAuthorLink().attributes('href')).toBe('/some_user'); + expect(findTimeAgoTooltip().exists()).toBe(true); }); it('if no updatedAt is provided, no time element will be rendered', () => { - const editedComponent = new EditedComponent({ - propsData: { - updatedByName: 'Some User', - updatedByPath: '/some_user', - }, - }).$mount(); - - expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited by Some User/); - expect(editedComponent.$el.querySelector('.author-link').href).toMatch(/\/some_user$/); - expect(editedComponent.$el.querySelector('time')).toBeFalsy(); + wrapper = mountComponent({ + updatedByName: 'Some User', + updatedByPath: '/some_user', + }); + + expect(formatText(wrapper.text())).toBe('Edited by Some User'); + expect(findAuthorLink().attributes('href')).toBe('/some_user'); + expect(findTimeAgoTooltip().exists()).toBe(false); }); it('if no updatedByName and updatedByPath is provided, no user element will be rendered', () => { - const editedComponent = new EditedComponent({ - propsData: { - updatedAt: '2017-05-15T12:31:04.428Z', - }, - }).$mount(); - - expect(formatText(editedComponent.$el.innerText)).not.toMatch(/by Some User/); - expect(editedComponent.$el.querySelector('.author-link')).toBeFalsy(); - expect(editedComponent.$el.querySelector('time')).toBeTruthy(); + wrapper = mountComponent({ + updatedAt: '2017-05-15T12:31:04.428Z', + }); + + expect(formatText(wrapper.text())).toBe('Edited'); + expect(findAuthorLink().exists()).toBe(false); + expect(findTimeAgoTooltip().exists()).toBe(true); }); }); diff --git a/spec/frontend/issues/show/components/incidents/highlight_bar_spec.js b/spec/frontend/issues/show/components/incidents/highlight_bar_spec.js index a4910d63bb5..155ae703e48 100644 --- a/spec/frontend/issues/show/components/incidents/highlight_bar_spec.js +++ b/spec/frontend/issues/show/components/incidents/highlight_bar_spec.js @@ -74,7 +74,7 @@ describe('Highlight Bar', () => { }); it('renders a number of alert events', () => { - expect(wrapper.text()).toContain(alert.eventCount); + expect(wrapper.text()).toContain(alert.eventCount.toString()); }); }); diff --git a/spec/frontend/issues/show/components/incidents/mock_data.js b/spec/frontend/issues/show/components/incidents/mock_data.js index b5346a6089a..afc6099caf4 100644 --- a/spec/frontend/issues/show/components/incidents/mock_data.js +++ b/spec/frontend/issues/show/components/incidents/mock_data.js @@ -70,3 +70,36 @@ export const timelineEventsQueryEmptyResponse = { }, }, }; + +export const timelineEventsCreateEventResponse = { + timelineEvent: { + ...mockEvents[0], + }, + errors: [], +}; + +export const timelineEventsCreateEventError = { + data: { + timelineEventCreate: { + timelineEvent: { + ...mockEvents[0], + }, + errors: ['Create error'], + }, + }, +}; + +const timelineEventDeleteData = (errors = []) => { + return { + data: { + timelineEventDestroy: { + timelineEvent: { ...mockEvents[0] }, + errors, + }, + }, + }; +}; + +export const timelineEventsDeleteEventResponse = timelineEventDeleteData(); + +export const timelineEventsDeleteEventError = timelineEventDeleteData(['Item does not exist']); diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js new file mode 100644 index 00000000000..620cdfc53b0 --- /dev/null +++ b/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js @@ -0,0 +1,181 @@ +import VueApollo from 'vue-apollo'; +import Vue, { nextTick } from 'vue'; +import { GlDatepicker } from '@gitlab/ui'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import IncidentTimelineEventForm from '~/issues/show/components/incidents/timeline_events_form.vue'; +import createTimelineEventMutation from '~/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { createAlert } from '~/flash'; +import { useFakeDate } from 'helpers/fake_date'; +import { timelineEventsCreateEventResponse, timelineEventsCreateEventError } from './mock_data'; + +Vue.use(VueApollo); + +jest.mock('~/flash'); + +const addEventResponse = jest.fn().mockResolvedValue(timelineEventsCreateEventResponse); + +function createMockApolloProvider(response = addEventResponse) { + const requestHandlers = [[createTimelineEventMutation, response]]; + return createMockApollo(requestHandlers); +} + +describe('Timeline events form', () => { + // July 8 2020 + useFakeDate(2020, 6, 8); + let wrapper; + + const mountComponent = ({ mockApollo, mountMethod = shallowMountExtended, stubs }) => { + wrapper = mountMethod(IncidentTimelineEventForm, { + propsData: { + hasTimelineEvents: true, + }, + provide: { + fullPath: 'group/project', + issuableId: '1', + }, + apolloProvider: mockApollo, + stubs, + }); + }; + + afterEach(() => { + addEventResponse.mockReset(); + createAlert.mockReset(); + if (wrapper) { + wrapper.destroy(); + } + }); + + const findSubmitButton = () => wrapper.findByText('Save'); + const findSubmitAndAddButton = () => wrapper.findByText('Save and add another event'); + const findCancelButton = () => wrapper.findByText('Cancel'); + const findDatePicker = () => wrapper.findComponent(GlDatepicker); + const findDatePickerInput = () => wrapper.findByTestId('input-datepicker'); + const findHourInput = () => wrapper.findByTestId('input-hours'); + const findMinuteInput = () => wrapper.findByTestId('input-minutes'); + const setDatetime = () => { + findDatePicker().vm.$emit('input', new Date('2021-08-12')); + findHourInput().vm.$emit('input', 5); + findMinuteInput().vm.$emit('input', 45); + }; + + const submitForm = async () => { + findSubmitButton().trigger('click'); + await waitForPromises(); + }; + const submitFormAndAddAnother = async () => { + findSubmitAndAddButton().trigger('click'); + await waitForPromises(); + }; + const cancelForm = async () => { + findCancelButton().trigger('click'); + await waitForPromises(); + }; + + describe('form button behaviour', () => { + const closeFormEvent = { 'hide-incident-timeline-event-form': [[]] }; + beforeEach(() => { + mountComponent({ mockApollo: createMockApolloProvider(), mountMethod: mountExtended }); + }); + + it('should close the form on submit', async () => { + await submitForm(); + expect(wrapper.emitted()).toEqual(closeFormEvent); + }); + + it('should not close the form on "submit and add another"', async () => { + await submitFormAndAddAnother(); + expect(wrapper.emitted()).toEqual({}); + }); + + it('should close the form on cancel', async () => { + await cancelForm(); + expect(wrapper.emitted()).toEqual(closeFormEvent); + }); + + it('should clear the form', async () => { + setDatetime(); + await nextTick(); + + expect(findDatePickerInput().element.value).toBe('2021-08-12'); + expect(findHourInput().element.value).toBe('5'); + expect(findMinuteInput().element.value).toBe('45'); + + wrapper.vm.clear(); + await nextTick(); + + expect(findDatePickerInput().element.value).toBe('2020-07-08'); + expect(findHourInput().element.value).toBe('0'); + expect(findMinuteInput().element.value).toBe('0'); + }); + }); + + describe('addTimelineEventQuery', () => { + const expectedData = { + input: { + incidentId: 'gid://gitlab/Issue/1', + note: '', + occurredAt: '2020-07-08T00:00:00.000Z', + }, + }; + + let mockApollo; + + beforeEach(() => { + mockApollo = createMockApolloProvider(); + mountComponent({ mockApollo, mountMethod: mountExtended }); + }); + + it('should call the mutation with the right variables', async () => { + await submitForm(); + + expect(addEventResponse).toHaveBeenCalledWith(expectedData); + }); + + it('should call the mutation with user selected variables', async () => { + const expectedUserSelectedData = { + input: { + ...expectedData.input, + occurredAt: '2021-08-12T05:45:00.000Z', + }, + }; + + setDatetime(); + + await nextTick(); + await submitForm(); + + expect(addEventResponse).toHaveBeenCalledWith(expectedUserSelectedData); + }); + }); + + describe('error handling', () => { + it('should show an error when submission returns an error', async () => { + const expectedAlertArgs = { + message: 'Error creating incident timeline event: Create error', + }; + addEventResponse.mockResolvedValueOnce(timelineEventsCreateEventError); + mountComponent({ mockApollo: createMockApolloProvider(), mountMethod: mountExtended }); + + await submitForm(); + + expect(createAlert).toHaveBeenCalledWith(expectedAlertArgs); + }); + + it('should show an error when submission fails', async () => { + const expectedAlertArgs = { + captureError: true, + error: new Error(), + message: 'Something went wrong while creating the incident timeline event.', + }; + addEventResponse.mockRejectedValueOnce(); + mountComponent({ mockApollo: createMockApolloProvider(), mountMethod: mountExtended }); + + await submitForm(); + + expect(createAlert).toHaveBeenCalledWith(expectedAlertArgs); + }); + }); +}); diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_list_item_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_list_item_spec.js index 7e51219ffa7..e686f2eb4ec 100644 --- a/spec/frontend/issues/show/components/incidents/timeline_events_list_item_spec.js +++ b/spec/frontend/issues/show/components/incidents/timeline_events_list_item_spec.js @@ -1,6 +1,6 @@ import timezoneMock from 'timezone-mock'; -import merge from 'lodash/merge'; -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlDropdown } from '@gitlab/ui'; +import { nextTick } from 'vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import IncidentTimelineEventListItem from '~/issues/show/components/incidents/timeline_events_list_item.vue'; import { mockEvents } from './mock_data'; @@ -8,25 +8,28 @@ import { mockEvents } from './mock_data'; describe('IncidentTimelineEventList', () => { let wrapper; - const mountComponent = (propsData) => { + const mountComponent = ({ propsData, provide } = {}) => { const { action, noteHtml, occurredAt } = mockEvents[0]; - wrapper = mountExtended( - IncidentTimelineEventListItem, - merge({ - propsData: { - action, - noteHtml, - occurredAt, - isLastItem: false, - ...propsData, - }, - }), - ); + wrapper = mountExtended(IncidentTimelineEventListItem, { + propsData: { + action, + noteHtml, + occurredAt, + isLastItem: false, + ...propsData, + }, + provide: { + canUpdate: false, + ...provide, + }, + }); }; const findCommentIcon = () => wrapper.findComponent(GlIcon); const findTextContainer = () => wrapper.findByTestId('event-text-container'); const findEventTime = () => wrapper.findByTestId('event-time'); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDeleteButton = () => wrapper.findByText('Delete'); describe('template', () => { it('shows comment icon', () => { @@ -55,7 +58,7 @@ describe('IncidentTimelineEventList', () => { }); it('does not show a bottom border when the last item', () => { - mountComponent({ isLastItem: true }); + mountComponent({ propsData: { isLastItem: true } }); expect(wrapper.classes()).not.toContain('gl-border-1'); }); @@ -83,5 +86,31 @@ describe('IncidentTimelineEventList', () => { }); }); }); + + describe('action dropdown', () => { + it('does not show the action dropdown by default', () => { + mountComponent(); + + expect(findDropdown().exists()).toBe(false); + expect(findDeleteButton().exists()).toBe(false); + }); + + it('shows dropdown and delete item when user has update permission', () => { + mountComponent({ provide: { canUpdate: true } }); + + expect(findDropdown().exists()).toBe(true); + expect(findDeleteButton().exists()).toBe(true); + }); + + it('triggers a delete when the delete button is clicked', async () => { + mountComponent({ provide: { canUpdate: true } }); + + findDeleteButton().trigger('click'); + + await nextTick(); + + expect(wrapper.emitted().delete).toBeTruthy(); + }); + }); }); }); diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js index 6610ea0b832..ae07237cf7d 100644 --- a/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js +++ b/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js @@ -1,41 +1,81 @@ import timezoneMock from 'timezone-mock'; -import merge from 'lodash/merge'; -import { shallowMountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import IncidentTimelineEventList from '~/issues/show/components/incidents/timeline_events_list.vue'; -import { mockEvents } from './mock_data'; +import IncidentTimelineEventListItem from '~/issues/show/components/incidents/timeline_events_list_item.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import deleteTimelineEventMutation from '~/issues/show/components/incidents/graphql/queries/delete_timeline_event.mutation.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { createAlert } from '~/flash'; +import { + mockEvents, + timelineEventsDeleteEventResponse, + timelineEventsDeleteEventError, +} from './mock_data'; + +Vue.use(VueApollo); + +jest.mock('~/flash'); +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); + +const deleteEventResponse = jest.fn(); + +function createMockApolloProvider() { + deleteEventResponse.mockResolvedValue(timelineEventsDeleteEventResponse); + const requestHandlers = [[deleteTimelineEventMutation, deleteEventResponse]]; + return createMockApollo(requestHandlers); +} + +const mockConfirmAction = ({ confirmed }) => { + confirmAction.mockResolvedValueOnce(confirmed); +}; describe('IncidentTimelineEventList', () => { let wrapper; - const mountComponent = () => { - wrapper = shallowMountExtended( - IncidentTimelineEventList, - merge({ - provide: { - fullPath: 'group/project', - issuableId: '1', - }, - propsData: { - timelineEvents: mockEvents, - }, - }), - ); + const mountComponent = (mockApollo) => { + const apollo = mockApollo ? { apolloProvider: mockApollo } : {}; + + wrapper = shallowMountExtended(IncidentTimelineEventList, { + provide: { + fullPath: 'group/project', + issuableId: '1', + }, + propsData: { + timelineEvents: mockEvents, + }, + ...apollo, + }); }; - const findGroups = () => wrapper.findAllByTestId('timeline-group'); - const findItems = (base = wrapper) => base.findAllByTestId('timeline-event'); - const findFirstGroup = () => extendedWrapper(findGroups().at(0)); - const findSecondGroup = () => extendedWrapper(findGroups().at(1)); + const findTimelineEventGroups = () => wrapper.findAllByTestId('timeline-group'); + const findItems = (base = wrapper) => base.findAll(IncidentTimelineEventListItem); + const findFirstTimelineEventGroup = () => findTimelineEventGroups().at(0); + const findSecondTimelineEventGroup = () => findTimelineEventGroups().at(1); const findDates = () => wrapper.findAllByTestId('event-date'); + const clickFirstDeleteButton = async () => { + findItems() + .at(0) + .vm.$emit('delete', { ...mockEvents[0] }); + await waitForPromises(); + }; + + afterEach(() => { + confirmAction.mockReset(); + deleteEventResponse.mockReset(); + wrapper.destroy(); + }); describe('template', () => { it('groups items correctly', () => { mountComponent(); - expect(findGroups()).toHaveLength(2); + expect(findTimelineEventGroups()).toHaveLength(2); - expect(findItems(findFirstGroup())).toHaveLength(1); - expect(findItems(findSecondGroup())).toHaveLength(2); + expect(findItems(findFirstTimelineEventGroup())).toHaveLength(1); + expect(findItems(findSecondTimelineEventGroup())).toHaveLength(2); }); it('sets the isLastItem prop correctly', () => { @@ -83,5 +123,48 @@ describe('IncidentTimelineEventList', () => { }); }); }); + + describe('delete functionality', () => { + beforeEach(() => { + mockConfirmAction({ confirmed: true }); + }); + + it('should delete when button is clicked', async () => { + const expectedVars = { input: { id: mockEvents[0].id } }; + + mountComponent(createMockApolloProvider()); + + await clickFirstDeleteButton(); + + expect(deleteEventResponse).toHaveBeenCalledWith(expectedVars); + }); + + it('should show an error when delete returns an error', async () => { + const expectedError = { + message: 'Error deleting incident timeline event: Item does not exist', + }; + + mountComponent(createMockApolloProvider()); + deleteEventResponse.mockResolvedValue(timelineEventsDeleteEventError); + + await clickFirstDeleteButton(); + + expect(createAlert).toHaveBeenCalledWith(expectedError); + }); + + it('should show an error when delete fails', async () => { + const expectedAlertArgs = { + captureError: true, + error: new Error(), + message: 'Something went wrong while deleting the incident timeline event.', + }; + mountComponent(createMockApolloProvider()); + deleteEventResponse.mockRejectedValueOnce(); + + await clickFirstDeleteButton(); + + expect(createAlert).toHaveBeenCalledWith(expectedAlertArgs); + }); + }); }); }); diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_tab_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_tab_spec.js index cf81f4cdf66..2d87851a761 100644 --- a/spec/frontend/issues/show/components/incidents/timeline_events_tab_spec.js +++ b/spec/frontend/issues/show/components/incidents/timeline_events_tab_spec.js @@ -1,13 +1,15 @@ -import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; import Vue from 'vue'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import TimelineEventsTab from '~/issues/show/components/incidents/timeline_events_tab.vue'; import IncidentTimelineEventsList from '~/issues/show/components/incidents/timeline_events_list.vue'; +import IncidentTimelineEventForm from '~/issues/show/components/incidents/timeline_events_form.vue'; import timelineEventsQuery from '~/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import { createAlert } from '~/flash'; +import { timelineTabI18n } from '~/issues/show/components/incidents/constants'; import { timelineEventsQueryListResponse, timelineEventsQueryEmptyResponse } from './mock_data'; Vue.use(VueApollo); @@ -28,14 +30,17 @@ describe('TimelineEventsTab', () => { let wrapper; const mountComponent = (options = {}) => { - const { mockApollo, mountMethod = shallowMountExtended } = options; + const { mockApollo, mountMethod = shallowMountExtended, stubs, provide } = options; wrapper = mountMethod(TimelineEventsTab, { provide: { fullPath: 'group/project', issuableId: '1', + canUpdate: true, + ...provide, }, apolloProvider: mockApollo, + stubs, }); }; @@ -48,6 +53,8 @@ describe('TimelineEventsTab', () => { const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon); const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findTimelineEventsList = () => wrapper.findComponent(IncidentTimelineEventsList); + const findTimelineEventForm = () => wrapper.findComponent(IncidentTimelineEventForm); + const findAddEventButton = () => wrapper.findByText(timelineTabI18n.addEventButton); describe('Timeline events tab', () => { describe('empty state', () => { @@ -82,24 +89,85 @@ describe('TimelineEventsTab', () => { describe('timelineEventsQuery', () => { let mockApollo; - beforeEach(() => { + const setup = () => { mockApollo = createMockApolloProvider(); mountComponent({ mockApollo }); - }); + }; it('should request data', () => { + setup(); + expect(listResponse).toHaveBeenCalled(); }); it('should show the loading state', () => { + setup(); + expect(findEmptyState().exists()).toBe(false); expect(findLoadingSpinner().exists()).toBe(true); }); it('should render the list', async () => { + setup(); await waitForPromises(); + expect(findEmptyState().exists()).toBe(false); expect(findTimelineEventsList().props('timelineEvents')).toHaveLength(3); }); }); + + describe('add new event form', () => { + beforeEach(async () => { + mountComponent({ + mockApollo: createMockApolloProvider(emptyResponse), + mountMethod: mountExtended, + stubs: { + 'incident-timeline-events-list': true, + 'gl-tab': true, + }, + }); + await waitForPromises(); + }); + + it('should show a button when user can update', () => { + expect(findAddEventButton().exists()).toBe(true); + }); + + it('should not show a button when user cannot update', () => { + mountComponent({ + mockApollo: createMockApolloProvider(emptyResponse), + provide: { canUpdate: false }, + }); + + expect(findAddEventButton().exists()).toBe(false); + }); + + it('should not show a form by default', () => { + expect(findTimelineEventForm().isVisible()).toBe(false); + }); + + it('should show a form when button is clicked', async () => { + await findAddEventButton().trigger('click'); + + expect(findTimelineEventForm().isVisible()).toBe(true); + }); + + it('should clear the form when button is clicked', async () => { + const mockClear = jest.fn(); + wrapper.vm.$refs.eventForm.clear = mockClear; + + await findAddEventButton().trigger('click'); + + expect(mockClear).toHaveBeenCalled(); + }); + + it('should hide the form when the hide event is emitted', async () => { + // open the form + await findAddEventButton().trigger('click'); + + await findTimelineEventForm().vm.$emit('hide-incident-timeline-event-form'); + + expect(findTimelineEventForm().isVisible()).toBe(false); + }); + }); }); diff --git a/spec/frontend/issues/show/components/incidents/utils_spec.js b/spec/frontend/issues/show/components/incidents/utils_spec.js index e6f7082d280..0da0114c654 100644 --- a/spec/frontend/issues/show/components/incidents/utils_spec.js +++ b/spec/frontend/issues/show/components/incidents/utils_spec.js @@ -1,4 +1,9 @@ -import { displayAndLogError, getEventIcon } from '~/issues/show/components/incidents/utils'; +import timezoneMock from 'timezone-mock'; +import { + displayAndLogError, + getEventIcon, + getUtcShiftedDateNow, +} from '~/issues/show/components/incidents/utils'; import { createAlert } from '~/flash'; jest.mock('~/flash'); @@ -19,13 +24,31 @@ describe('incident utils', () => { describe('get event icon', () => { it('should display a matching event icon name', () => { - const name = 'comment'; - - expect(getEventIcon(name)).toBe(name); + ['comment', 'issues', 'status'].forEach((name) => { + expect(getEventIcon(name)).toBe(name); + }); }); it('should return a default icon name', () => { expect(getEventIcon('non-existent-icon-name')).toBe('comment'); }); }); + + describe('getUtcShiftedDateNow', () => { + beforeEach(() => { + timezoneMock.register('US/Pacific'); + }); + + afterEach(() => { + timezoneMock.unregister(); + }); + + it('should shift the date by the timezone offset', () => { + const date = new Date(); + + const shiftedDate = getUtcShiftedDateNow(); + + expect(shiftedDate > date).toBe(true); + }); + }); }); diff --git a/spec/frontend/jobs/bridge/app_spec.js b/spec/frontend/jobs/bridge/app_spec.js deleted file mode 100644 index 210dcfa364b..00000000000 --- a/spec/frontend/jobs/bridge/app_spec.js +++ /dev/null @@ -1,146 +0,0 @@ -import Vue, { nextTick } from 'vue'; -import { shallowMount } 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'; - -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 createComponentWithApollo = () => { - const handlers = [[getPipelineQuery, mockPipelineQuery]]; - Vue.use(VueApollo); - mockApollo = createMockApollo(handlers); - - createComponent({ - 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('while pipeline query is loading', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders loading icon', () => { - expect(findLoadingIcon().exists()).toBe(true); - }); - }); - - describe('after pipeline query is loaded', () => { - beforeEach(async () => { - mockPipelineQuery.mockResolvedValue(mockPipelineQueryResponse); - createComponentWithApollo(); - await 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); - }); - - it('renders sidebar', () => { - expect(findSidebar().exists()).toBe(true); - }); - }); - - describe('sidebar expansion', () => { - beforeEach(async () => { - mockPipelineQuery.mockResolvedValue(mockPipelineQueryResponse); - createComponentWithApollo(); - await 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 deleted file mode 100644 index 38c55b296f0..00000000000 --- a/spec/frontend/jobs/bridge/components/empty_state_spec.js +++ /dev/null @@ -1,58 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import BridgeEmptyState from '~/jobs/bridge/components/empty_state.vue'; -import { MOCK_EMPTY_ILLUSTRATION_PATH, MOCK_PATH_TO_DOWNSTREAM } from '../mock_data'; - -describe('Bridge Empty State', () => { - let wrapper; - - const createComponent = ({ downstreamPipelinePath }) => { - wrapper = shallowMount(BridgeEmptyState, { - provide: { - emptyStateIllustrationPath: MOCK_EMPTY_ILLUSTRATION_PATH, - }, - propsData: { - downstreamPipelinePath, - }, - }); - }; - - const findSvg = () => wrapper.find('img'); - const findTitle = () => wrapper.find('h1'); - const findLinkBtn = () => wrapper.findComponent(GlButton); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('template', () => { - beforeEach(() => { - createComponent({ downstreamPipelinePath: MOCK_PATH_TO_DOWNSTREAM }); - }); - - it('renders illustration', () => { - expect(findSvg().exists()).toBe(true); - }); - - it('renders title', () => { - expect(findTitle().exists()).toBe(true); - expect(findTitle().text()).toBe(wrapper.vm.$options.i18n.title); - }); - - it('renders CTA button', () => { - expect(findLinkBtn().exists()).toBe(true); - expect(findLinkBtn().text()).toBe(wrapper.vm.$options.i18n.linkBtnText); - expect(findLinkBtn().attributes('href')).toBe(MOCK_PATH_TO_DOWNSTREAM); - }); - }); - - describe('without downstream pipeline', () => { - beforeEach(() => { - createComponent({ downstreamPipelinePath: undefined }); - }); - - it('does not render CTA button', () => { - expect(findLinkBtn().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/jobs/bridge/components/sidebar_spec.js b/spec/frontend/jobs/bridge/components/sidebar_spec.js deleted file mode 100644 index 5006d4f08a6..00000000000 --- a/spec/frontend/jobs/bridge/components/sidebar_spec.js +++ /dev/null @@ -1,99 +0,0 @@ -import { GlButton, GlDropdown } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import BridgeSidebar from '~/jobs/bridge/components/sidebar.vue'; -import CommitBlock from '~/jobs/components/commit_block.vue'; -import { mockCommit, mockJob } from '../mock_data'; - -describe('Bridge Sidebar', () => { - let wrapper; - - const MockHeaderEl = { - getBoundingClientRect() { - return { - bottom: '40', - }; - }, - }; - - const createComponent = ({ featureFlag } = {}) => { - wrapper = shallowMount(BridgeSidebar, { - provide: { - glFeatures: { - triggerJobRetryAction: featureFlag, - }, - }, - propsData: { - bridgeJob: mockJob, - commit: mockCommit, - }, - }); - }; - - const findJobTitle = () => wrapper.find('h4'); - const findCommitBlock = () => wrapper.findComponent(CommitBlock); - const findRetryDropdown = () => wrapper.find(GlDropdown); - const findToggleBtn = () => wrapper.findComponent(GlButton); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('template', () => { - beforeEach(() => { - createComponent(); - }); - - 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;'); - }); - }); - - describe('sidebar expansion', () => { - beforeEach(() => { - createComponent(); - }); - - it('emits toggle sidebar event on button click', async () => { - expect(wrapper.emitted('toggleSidebar')).toBe(undefined); - - findToggleBtn().vm.$emit('click'); - - expect(wrapper.emitted('toggleSidebar')).toHaveLength(1); - }); - }); - - 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 deleted file mode 100644 index 4084bb54163..00000000000 --- a/spec/frontend/jobs/bridge/mock_data.js +++ /dev/null @@ -1,102 +0,0 @@ -export const MOCK_EMPTY_ILLUSTRATION_PATH = '/path/to/svg'; -export const MOCK_PATH_TO_DOWNSTREAM = '/path/to/downstream/pipeline'; -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/job_app_spec.js b/spec/frontend/jobs/components/job_app_spec.js index fc308766ab9..b4b5bc4669d 100644 --- a/spec/frontend/jobs/components/job_app_spec.js +++ b/spec/frontend/jobs/components/job_app_spec.js @@ -22,7 +22,6 @@ describe('Job App', () => { let store; let wrapper; let mock; - let origGon; const initSettings = { endpoint: `${TEST_HOST}jobs/123.json`, @@ -80,17 +79,11 @@ describe('Job App', () => { beforeEach(() => { mock = new MockAdapter(axios); store = createStore(); - - origGon = window.gon; - - window.gon = { features: { infinitelyCollapsibleSections: false } }; // NOTE: All of this passes with the feature flag }); afterEach(() => { wrapper.destroy(); mock.restore(); - - window.gon = origGon; }); describe('while loading', () => { diff --git a/spec/frontend/jobs/components/job_log_controllers_spec.js b/spec/frontend/jobs/components/job_log_controllers_spec.js index cd3ee734466..cc97d111c06 100644 --- a/spec/frontend/jobs/components/job_log_controllers_spec.js +++ b/spec/frontend/jobs/components/job_log_controllers_spec.js @@ -1,6 +1,11 @@ +import { GlSearchBoxByClick } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import JobLogControllers from '~/jobs/components/job_log_controllers.vue'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import { mockJobLog } from '../mock_data'; + +const mockToastShow = jest.fn(); describe('Job log controllers', () => { let wrapper; @@ -19,14 +24,30 @@ describe('Job log controllers', () => { isScrollBottomDisabled: false, isScrollingDown: true, isJobLogSizeVisible: true, + jobLog: mockJobLog, }; - const createWrapper = (props) => { + const createWrapper = (props, jobLogSearch = false) => { wrapper = mount(JobLogControllers, { propsData: { ...defaultProps, ...props, }, + provide: { + glFeatures: { + jobLogSearch, + }, + }, + data() { + return { + searchTerm: '82', + }; + }, + mocks: { + $toast: { + show: mockToastShow, + }, + }, }); }; @@ -35,6 +56,8 @@ describe('Job log controllers', () => { const findRawLinkController = () => wrapper.find('[data-testid="job-raw-link-controller"]'); const findScrollTop = () => wrapper.find('[data-testid="job-controller-scroll-top"]'); const findScrollBottom = () => wrapper.find('[data-testid="job-controller-scroll-bottom"]'); + const findJobLogSearch = () => wrapper.findComponent(GlSearchBoxByClick); + const findSearchHelp = () => wrapper.findComponent(HelpPopover); describe('Truncate information', () => { describe('with isJobLogSizeVisible', () => { @@ -179,4 +202,40 @@ describe('Job log controllers', () => { }); }); }); + + describe('Job log search', () => { + describe('with feature flag off', () => { + it('does not display job log search', () => { + createWrapper(); + + expect(findJobLogSearch().exists()).toBe(false); + expect(findSearchHelp().exists()).toBe(false); + }); + }); + + describe('with feature flag on', () => { + beforeEach(() => { + createWrapper({}, { jobLogSearch: true }); + }); + + it('displays job log search', () => { + expect(findJobLogSearch().exists()).toBe(true); + expect(findSearchHelp().exists()).toBe(true); + }); + + it('emits search results', () => { + const expectedSearchResults = [[[mockJobLog[6].lines[1], mockJobLog[6].lines[2]]]]; + + findJobLogSearch().vm.$emit('submit'); + + expect(wrapper.emitted('searchResults')).toEqual(expectedSearchResults); + }); + + it('clears search results', () => { + findJobLogSearch().vm.$emit('clear'); + + expect(wrapper.emitted('searchResults')).toEqual([[[]]]); + }); + }); + }); }); diff --git a/spec/frontend/jobs/components/job_sidebar_details_container_spec.js b/spec/frontend/jobs/components/job_sidebar_details_container_spec.js index cc9a5e4ee25..4046f0269dd 100644 --- a/spec/frontend/jobs/components/job_sidebar_details_container_spec.js +++ b/spec/frontend/jobs/components/job_sidebar_details_container_spec.js @@ -42,7 +42,7 @@ describe('Job Sidebar Details Container', () => { expect(wrapper.html()).toBe(''); }); - it.each(['duration', 'erased_at', 'finished_at', 'queued', 'runner', 'coverage'])( + it.each(['duration', 'erased_at', 'finished_at', 'queued_at', 'runner', 'coverage'])( 'should not render %s details when missing', async (detail) => { await store.dispatch('receiveJobSuccess', { [detail]: undefined }); @@ -59,7 +59,7 @@ describe('Job Sidebar Details Container', () => { ['duration', 'Elapsed time: 6 seconds'], ['erased_at', 'Erased: 3 weeks ago'], ['finished_at', 'Finished: 3 weeks ago'], - ['queued', 'Queued: 9 seconds'], + ['queued_duration', 'Queued: 9 seconds'], ['runner', 'Runner: #1 (ABCDEFGH) local ci runner'], ['coverage', 'Coverage: 20%'], ])('uses %s to render job-%s', async (detail, value) => { diff --git a/spec/frontend/jobs/components/jobs_container_spec.js b/spec/frontend/jobs/components/jobs_container_spec.js index 1cde72682a2..127570b8184 100644 --- a/spec/frontend/jobs/components/jobs_container_spec.js +++ b/spec/frontend/jobs/components/jobs_container_spec.js @@ -106,7 +106,7 @@ describe('Jobs List block', () => { }); expect(findJob().text()).toBe(job.name); - expect(findJob().text()).not.toContain(job.id); + expect(findJob().text()).not.toContain(job.id.toString()); }); it('renders job id when job name is not available', () => { diff --git a/spec/frontend/jobs/components/log/collapsible_section_spec.js b/spec/frontend/jobs/components/log/collapsible_section_spec.js index 2ab7f5fe22d..646935568b1 100644 --- a/spec/frontend/jobs/components/log/collapsible_section_spec.js +++ b/spec/frontend/jobs/components/log/collapsible_section_spec.js @@ -5,7 +5,6 @@ import { collapsibleSectionClosed, collapsibleSectionOpened } from './mock_data' describe('Job Log Collapsible Section', () => { let wrapper; - let origGon; const jobLogEndpoint = 'jobs/335'; @@ -20,16 +19,8 @@ describe('Job Log Collapsible Section', () => { }); }; - beforeEach(() => { - origGon = window.gon; - - window.gon = { features: { infinitelyCollapsibleSections: false } }; // NOTE: This also works with true - }); - afterEach(() => { wrapper.destroy(); - - window.gon = origGon; }); describe('with closed section', () => { diff --git a/spec/frontend/jobs/components/log/line_spec.js b/spec/frontend/jobs/components/log/line_spec.js index d184696cd1f..bf80d90e299 100644 --- a/spec/frontend/jobs/components/log/line_spec.js +++ b/spec/frontend/jobs/components/log/line_spec.js @@ -179,4 +179,46 @@ describe('Job Log Line', () => { expect(findLink().exists()).toBe(false); }); }); + + describe('job log search', () => { + const mockSearchResults = [ + { + offset: 1533, + content: [{ text: '$ echo "82.71"', style: 'term-fg-l-green term-bold' }], + section: 'step-script', + lineNumber: 20, + }, + { offset: 1560, content: [{ text: '82.71' }], section: 'step-script', lineNumber: 21 }, + ]; + + it('applies highlight class to search result elements', () => { + createComponent({ + line: { + offset: 1560, + content: [{ text: '82.71' }], + section: 'step-script', + lineNumber: 21, + }, + path: '/root/ci-project/-/jobs/1089', + searchResults: mockSearchResults, + }); + + expect(wrapper.classes()).toContain('gl-bg-gray-500'); + }); + + it('does not apply highlight class to search result elements', () => { + createComponent({ + line: { + offset: 1560, + content: [{ text: 'docker' }], + section: 'step-script', + lineNumber: 29, + }, + path: '/root/ci-project/-/jobs/1089', + searchResults: mockSearchResults, + }); + + expect(wrapper.classes()).not.toContain('gl-bg-gray-500'); + }); + }); }); diff --git a/spec/frontend/jobs/components/log/log_spec.js b/spec/frontend/jobs/components/log/log_spec.js index 9cc56cce9b3..c933ed5c3e1 100644 --- a/spec/frontend/jobs/components/log/log_spec.js +++ b/spec/frontend/jobs/components/log/log_spec.js @@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; import Log from '~/jobs/components/log/log.vue'; -import { logLinesParserLegacy, logLinesParser } from '~/jobs/store/utils'; +import { logLinesParser } from '~/jobs/store/utils'; import { jobLog } from './mock_data'; describe('Job Log', () => { @@ -10,7 +10,6 @@ describe('Job Log', () => { let actions; let state; let store; - let origGon; Vue.use(Vuex); @@ -25,12 +24,8 @@ describe('Job Log', () => { toggleCollapsibleLine: () => {}, }; - origGon = window.gon; - - window.gon = { features: { infinitelyCollapsibleSections: false } }; - state = { - jobLog: logLinesParserLegacy(jobLog), + jobLog: logLinesParser(jobLog), jobLogEndpoint: 'jobs/id', }; @@ -44,88 +39,6 @@ describe('Job Log', () => { afterEach(() => { wrapper.destroy(); - - window.gon = origGon; - }); - - const findCollapsibleLine = () => wrapper.find('.collapsible-line'); - - describe('line numbers', () => { - it('renders a line number for each open line', () => { - expect(wrapper.find('#L1').text()).toBe('1'); - expect(wrapper.find('#L2').text()).toBe('2'); - expect(wrapper.find('#L3').text()).toBe('3'); - }); - - it('links to the provided path and correct line number', () => { - expect(wrapper.find('#L1').attributes('href')).toBe(`${state.jobLogEndpoint}#L1`); - }); - }); - - describe('collapsible sections', () => { - it('renders a clickable header section', () => { - expect(findCollapsibleLine().attributes('role')).toBe('button'); - }); - - it('renders an icon with the open state', () => { - expect(findCollapsibleLine().find('[data-testid="chevron-lg-down-icon"]').exists()).toBe( - true, - ); - }); - - describe('on click header section', () => { - it('calls toggleCollapsibleLine', () => { - jest.spyOn(wrapper.vm, 'toggleCollapsibleLine'); - - findCollapsibleLine().trigger('click'); - - expect(wrapper.vm.toggleCollapsibleLine).toHaveBeenCalled(); - }); - }); - }); -}); - -describe('Job Log, infinitelyCollapsibleSections feature flag enabled', () => { - let wrapper; - let actions; - let state; - let store; - let origGon; - - Vue.use(Vuex); - - const createComponent = () => { - wrapper = mount(Log, { - store, - }); - }; - - beforeEach(() => { - actions = { - toggleCollapsibleLine: () => {}, - }; - - origGon = window.gon; - - window.gon = { features: { infinitelyCollapsibleSections: true } }; - - state = { - jobLog: logLinesParser(jobLog).parsedLines, - jobLogEndpoint: 'jobs/id', - }; - - store = new Vuex.Store({ - actions, - state, - }); - - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - - window.gon = origGon; }); const findCollapsibleLine = () => wrapper.find('.collapsible-line'); diff --git a/spec/frontend/jobs/components/log/mock_data.js b/spec/frontend/jobs/components/log/mock_data.js index 3ff0bd73581..eb8c4fe8bc9 100644 --- a/spec/frontend/jobs/components/log/mock_data.js +++ b/spec/frontend/jobs/components/log/mock_data.js @@ -58,80 +58,6 @@ export const utilsMockData = [ }, ]; -export const multipleCollapsibleSectionsMockData = [ - { - offset: 1001, - content: [{ text: ' on docker-auto-scale-com 8a6210b8' }], - }, - { - offset: 1002, - content: [ - { - text: 'Executing "step_script" stage of the job script', - }, - ], - section: 'step-script', - section_header: true, - }, - { - offset: 1003, - content: [{ text: 'sleep 60' }], - section: 'step-script', - }, - { - offset: 1004, - content: [ - { - text: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam lorem dolor, congue ac condimentum vitae', - }, - ], - section: 'step-script', - }, - { - offset: 1005, - content: [{ text: 'executing...' }], - section: 'step-script', - }, - { - offset: 1006, - content: [{ text: '1st collapsible section' }], - section: 'collapsible-1', - section_header: true, - }, - { - offset: 1007, - content: [ - { - text: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam lorem dolor, congue ac condimentum vitae', - }, - ], - section: 'collapsible-1', - }, - { - offset: 1008, - content: [], - section: 'collapsible-1', - section_duration: '01:00', - }, - { - offset: 1009, - content: [], - section: 'step-script', - section_duration: '10:00', - }, -]; - -export const backwardsCompatibilityTrace = [ - { - offset: 2365, - content: [], - section: 'download-artifacts', - section_duration: '00:01', - }, -]; - export const originalTrace = [ { offset: 1, diff --git a/spec/frontend/jobs/components/table/cells/actions_cell_spec.js b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js index 976b128532d..7cc008f332d 100644 --- a/spec/frontend/jobs/components/table/cells/actions_cell_spec.js +++ b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js @@ -12,17 +12,12 @@ import JobRetryMutation from '~/jobs/components/table/graphql/mutations/job_retr import JobUnscheduleMutation from '~/jobs/components/table/graphql/mutations/job_unschedule.mutation.graphql'; import JobCancelMutation from '~/jobs/components/table/graphql/mutations/job_cancel.mutation.graphql'; import { - playableJob, - retryableJob, - cancelableJob, - scheduledJob, - cannotRetryJob, - cannotPlayJob, - cannotPlayScheduledJob, - retryMutationResponse, + mockJobsNodes, + mockJobsNodesAsGuest, playMutationResponse, - cancelMutationResponse, + retryMutationResponse, unscheduleMutationResponse, + cancelMutationResponse, } from '../../../mock_data'; jest.mock('~/lib/utils/url_utility'); @@ -32,6 +27,22 @@ Vue.use(VueApollo); describe('Job actions cell', () => { let wrapper; + const findMockJob = (jobName, nodes = mockJobsNodes) => { + const job = nodes.find(({ name }) => name === jobName); + expect(job).toBeDefined(); // ensure job is present + return job; + }; + + const mockJob = findMockJob('build'); + const cancelableJob = findMockJob('cancelable'); + const playableJob = findMockJob('playable'); + const retryableJob = findMockJob('retryable'); + const scheduledJob = findMockJob('scheduled'); + const jobWithArtifact = findMockJob('with_artifact'); + const cannotPlayJob = findMockJob('playable', mockJobsNodesAsGuest); + const cannotRetryJob = findMockJob('retryable', mockJobsNodesAsGuest); + const cannotPlayScheduledJob = findMockJob('scheduled', mockJobsNodesAsGuest); + const findRetryButton = () => wrapper.findByTestId('retry'); const findPlayButton = () => wrapper.findByTestId('play'); const findCancelButton = () => wrapper.findByTestId('cancel-button'); @@ -55,10 +66,10 @@ describe('Job actions cell', () => { return createMockApollo(requestHandlers); }; - const createComponent = (jobType, requestHandlers, props = {}) => { + const createComponent = (job, requestHandlers, props = {}) => { wrapper = shallowMountExtended(ActionsCell, { propsData: { - job: jobType, + job, ...props, }, apolloProvider: createMockApolloProvider(requestHandlers), @@ -73,15 +84,15 @@ describe('Job actions cell', () => { }); it('displays the artifacts download button with correct link', () => { - createComponent(playableJob); + createComponent(jobWithArtifact); expect(findDownloadArtifactsButton().attributes('href')).toBe( - playableJob.artifacts.nodes[0].downloadPath, + jobWithArtifact.artifacts.nodes[0].downloadPath, ); }); it('does not display an artifacts download button', () => { - createComponent(retryableJob); + createComponent(mockJob); expect(findDownloadArtifactsButton().exists()).toBe(false); }); @@ -101,7 +112,7 @@ describe('Job actions cell', () => { button | action | jobType ${findPlayButton} | ${'play'} | ${playableJob} ${findRetryButton} | ${'retry'} | ${retryableJob} - ${findDownloadArtifactsButton} | ${'download artifacts'} | ${playableJob} + ${findDownloadArtifactsButton} | ${'download artifacts'} | ${jobWithArtifact} ${findCancelButton} | ${'cancel'} | ${cancelableJob} `('displays the $action button', ({ button, jobType }) => { createComponent(jobType); diff --git a/spec/frontend/jobs/components/table/cells/job_cell_spec.js b/spec/frontend/jobs/components/table/cells/job_cell_spec.js index fc4e5586349..ddc196129a7 100644 --- a/spec/frontend/jobs/components/table/cells/job_cell_spec.js +++ b/spec/frontend/jobs/components/table/cells/job_cell_spec.js @@ -2,16 +2,22 @@ import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import JobCell from '~/jobs/components/table/cells/job_cell.vue'; -import { mockJobsInTable } from '../../../mock_data'; - -const mockJob = mockJobsInTable[0]; -const mockJobCreatedByTag = mockJobsInTable[1]; -const mockJobLimitedAccess = mockJobsInTable[2]; -const mockStuckJob = mockJobsInTable[3]; +import { mockJobsNodes, mockJobsNodesAsGuest } from '../../../mock_data'; describe('Job Cell', () => { let wrapper; + const findMockJob = (jobName, nodes = mockJobsNodes) => { + const job = nodes.find(({ name }) => name === jobName); + expect(job).toBeDefined(); // ensure job is present + return job; + }; + + const mockJob = findMockJob('build'); + const jobCreatedByTag = findMockJob('created_by_tag'); + const pendingJob = findMockJob('pending'); + const jobAsGuest = findMockJob('build', mockJobsNodesAsGuest); + const findJobIdLink = () => wrapper.findByTestId('job-id-link'); const findJobIdNoLink = () => wrapper.findByTestId('job-id-limited-access'); const findJobRef = () => wrapper.findByTestId('job-ref'); @@ -23,11 +29,11 @@ describe('Job Cell', () => { const findBadgeById = (id) => wrapper.findByTestId(id); - const createComponent = (jobData = mockJob) => { + const createComponent = (job = mockJob) => { wrapper = extendedWrapper( shallowMount(JobCell, { propsData: { - job: jobData, + job, }, }), ); @@ -49,9 +55,9 @@ describe('Job Cell', () => { }); it('display the job id with no link', () => { - createComponent(mockJobLimitedAccess); + createComponent(jobAsGuest); - const expectedJobId = `#${getIdFromGraphQLId(mockJobLimitedAccess.id)}`; + const expectedJobId = `#${getIdFromGraphQLId(jobAsGuest.id)}`; expect(findJobIdNoLink().text()).toBe(expectedJobId); expect(findJobIdNoLink().exists()).toBe(true); @@ -75,7 +81,7 @@ describe('Job Cell', () => { }); it('displays label icon when job is created by a tag', () => { - createComponent(mockJobCreatedByTag); + createComponent(jobCreatedByTag); expect(findLabelIcon().exists()).toBe(true); expect(findForkIcon().exists()).toBe(false); @@ -130,8 +136,8 @@ describe('Job Cell', () => { expect(findStuckIcon().exists()).toBe(false); }); - it('stuck icon is shown if job is stuck', () => { - createComponent(mockStuckJob); + it('stuck icon is shown if job is pending', () => { + createComponent(pendingJob); expect(findStuckIcon().exists()).toBe(true); expect(findStuckIcon().attributes('name')).toBe('warning'); 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 986fba21fb9..374768c3ee4 100644 --- a/spec/frontend/jobs/components/table/job_table_app_spec.js +++ b/spec/frontend/jobs/components/table/job_table_app_spec.js @@ -6,7 +6,7 @@ import { GlLoadingIcon, } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { s__ } from '~/locale'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -18,8 +18,8 @@ import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue'; import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue'; import JobsFilteredSearch from '~/jobs/components/filtered_search/jobs_filtered_search.vue'; import { - mockJobsQueryResponse, - mockJobsQueryEmptyResponse, + mockJobsResponsePaginated, + mockJobsResponseEmpty, mockFailedSearchToken, } from '../../mock_data'; @@ -30,11 +30,10 @@ jest.mock('~/flash'); describe('Job table app', () => { let wrapper; - let jobsTableVueSearch = true; - const successHandler = jest.fn().mockResolvedValue(mockJobsQueryResponse); + const successHandler = jest.fn().mockResolvedValue(mockJobsResponsePaginated); const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); - const emptyHandler = jest.fn().mockResolvedValue(mockJobsQueryEmptyResponse); + const emptyHandler = jest.fn().mockResolvedValue(mockJobsResponseEmpty); const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon); @@ -66,7 +65,6 @@ describe('Job table app', () => { }, provide: { fullPath: projectPath, - glFeatures: { jobsTableVueSearch }, }, apolloProvider: createMockApolloProvider(handler), }); @@ -77,17 +75,17 @@ describe('Job table app', () => { }); describe('loading state', () => { - beforeEach(() => { + it('should display skeleton loader when loading', () => { createComponent(); - }); - it('should display skeleton loader when loading', () => { expect(findSkeletonLoader().exists()).toBe(true); expect(findTable().exists()).toBe(false); expect(findLoadingSpinner().exists()).toBe(false); }); it('when switching tabs only the skeleton loader should show', () => { + createComponent(); + findTabs().vm.$emit('fetchJobsByStatus', null); expect(findSkeletonLoader().exists()).toBe(true); @@ -119,24 +117,29 @@ describe('Job table app', () => { }); describe('when infinite scrolling is triggered', () => { - beforeEach(() => { + it('does not display a skeleton loader', () => { triggerInfiniteScroll(); - }); - it('does not display a skeleton loader', () => { expect(findSkeletonLoader().exists()).toBe(false); }); it('handles infinite scrolling by calling fetch more', async () => { + triggerInfiniteScroll(); + + await nextTick(); + + const pageSize = 30; + expect(findLoadingSpinner().exists()).toBe(true); await waitForPromises(); expect(findLoadingSpinner().exists()).toBe(false); - expect(successHandler).toHaveBeenCalledWith({ - after: 'eyJpZCI6IjIzMTcifQ', - fullPath: 'gitlab-org/gitlab', + expect(successHandler).toHaveBeenLastCalledWith({ + first: pageSize, + fullPath: projectPath, + after: mockJobsResponsePaginated.data.project.jobs.pageInfo.endCursor, }); }); }); @@ -227,13 +230,5 @@ describe('Job table app', () => { expect(createFlash).toHaveBeenCalledWith(expectedWarning); expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0); }); - - it('should not display filtered search', () => { - jobsTableVueSearch = false; - - createComponent(); - - expect(findFilteredSearch().exists()).toBe(false); - }); }); }); diff --git a/spec/frontend/jobs/components/table/jobs_table_spec.js b/spec/frontend/jobs/components/table/jobs_table_spec.js index ac8bef675f8..803df3df37f 100644 --- a/spec/frontend/jobs/components/table/jobs_table_spec.js +++ b/spec/frontend/jobs/components/table/jobs_table_spec.js @@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import JobsTable from '~/jobs/components/table/jobs_table.vue'; import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; -import { mockJobsInTable } from '../../mock_data'; +import { mockJobsNodes } from '../../mock_data'; describe('Jobs Table', () => { let wrapper; @@ -19,7 +19,7 @@ describe('Jobs Table', () => { wrapper = extendedWrapper( mount(JobsTable, { propsData: { - jobs: mockJobsInTable, + jobs: mockJobsNodes, ...props, }, }), @@ -39,7 +39,7 @@ describe('Jobs Table', () => { }); it('displays correct number of job rows', () => { - expect(findTableRows()).toHaveLength(mockJobsInTable.length); + expect(findTableRows()).toHaveLength(mockJobsNodes.length); }); it('displays job status', () => { @@ -47,14 +47,14 @@ describe('Jobs Table', () => { }); it('displays the job stage and name', () => { - const firstJob = mockJobsInTable[0]; + const firstJob = mockJobsNodes[0]; expect(findJobStage().text()).toBe(firstJob.stage.name); expect(findJobName().text()).toBe(firstJob.name); }); it('displays the coverage for only jobs that have coverage', () => { - const jobsThatHaveCoverage = mockJobsInTable.filter((job) => job.coverage !== null); + const jobsThatHaveCoverage = mockJobsNodes.filter((job) => job.coverage !== null); jobsThatHaveCoverage.forEach((job, index) => { expect(findAllCoverageJobs().at(index).text()).toBe(`${job.coverage}%`); diff --git a/spec/frontend/jobs/mock_data.js b/spec/frontend/jobs/mock_data.js index 4676635cce0..bf238b2e39a 100644 --- a/spec/frontend/jobs/mock_data.js +++ b/spec/frontend/jobs/mock_data.js @@ -1,8 +1,18 @@ +import mockJobsEmpty from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.empty.json'; +import mockJobsPaginated from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.paginated.json'; +import mockJobs from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.json'; +import mockJobsAsGuest from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.as_guest.json'; import { TEST_HOST } from 'spec/test_constants'; const threeWeeksAgo = new Date(); threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); +// Fixtures generated at spec/frontend/fixtures/jobs.rb +export const mockJobsResponsePaginated = mockJobsPaginated; +export const mockJobsResponseEmpty = mockJobsEmpty; +export const mockJobsNodes = mockJobs.data.project.jobs.nodes; +export const mockJobsNodesAsGuest = mockJobsAsGuest.data.project.jobs.nodes; + export const stages = [ { name: 'build', @@ -924,7 +934,7 @@ export default { created_at: threeWeeksAgo.toISOString(), updated_at: threeWeeksAgo.toISOString(), finished_at: threeWeeksAgo.toISOString(), - queued: 9.54, + queued_duration: 9.54, status: { icon: 'status_success', text: 'passed', @@ -1283,602 +1293,6 @@ export const mockPipelineDetached = { }, }; -export const mockJobsInTable = [ - { - detailedStatus: { - icon: 'status_manual', - label: 'manual play action', - text: 'manual', - tooltip: 'manual action', - action: { - buttonTitle: 'Trigger this manual action', - icon: 'play', - method: 'post', - path: '/root/ci-project/-/jobs/2004/play', - title: 'Play', - __typename: 'StatusAction', - }, - detailsPath: '/root/ci-project/-/jobs/2004', - __typename: 'DetailedStatus', - }, - id: 'gid://gitlab/Ci::Build/2004', - refName: 'main', - refPath: '/root/ci-project/-/commits/main', - tags: [], - shortSha: '2d5d8323', - commitPath: '/root/ci-project/-/commit/2d5d83230bdea0e003d83ef4c16d2bf9a8808ebe', - pipeline: { - id: 'gid://gitlab/Ci::Pipeline/423', - path: '/root/ci-project/-/pipelines/423', - user: { - webPath: '/root', - avatarUrl: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - __typename: 'User', - }, - __typename: 'Pipeline', - }, - stage: { name: 'test', __typename: 'CiStage' }, - name: 'test_manual_job', - duration: null, - finishedAt: null, - coverage: null, - createdByTag: false, - retryable: false, - playable: true, - cancelable: false, - active: false, - stuck: false, - userPermissions: { readBuild: true, __typename: 'JobPermissions' }, - __typename: 'CiJob', - }, - { - detailedStatus: { - icon: 'status_skipped', - label: 'skipped', - text: 'skipped', - tooltip: 'skipped', - action: null, - __typename: 'DetailedStatus', - }, - id: 'gid://gitlab/Ci::Build/2021', - refName: 'main', - refPath: '/root/ci-project/-/commits/main', - tags: [], - shortSha: '2d5d8323', - commitPath: '/root/ci-project/-/commit/2d5d83230bdea0e003d83ef4c16d2bf9a8808ebe', - pipeline: { - id: 'gid://gitlab/Ci::Pipeline/425', - path: '/root/ci-project/-/pipelines/425', - user: { - webPath: '/root', - avatarUrl: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - __typename: 'User', - }, - __typename: 'Pipeline', - }, - stage: { name: 'test', __typename: 'CiStage' }, - name: 'coverage_job', - duration: null, - finishedAt: null, - coverage: null, - createdByTag: true, - retryable: false, - playable: false, - cancelable: false, - active: false, - stuck: false, - userPermissions: { readBuild: true, __typename: 'JobPermissions' }, - __typename: 'CiJob', - }, - { - detailedStatus: { - icon: 'status_success', - label: 'passed', - text: 'passed', - tooltip: 'passed', - action: { - buttonTitle: 'Retry this job', - icon: 'retry', - method: 'post', - path: '/root/ci-project/-/jobs/2015/retry', - title: 'Retry', - __typename: 'StatusAction', - }, - __typename: 'DetailedStatus', - }, - id: 'gid://gitlab/Ci::Build/2015', - refName: 'main', - refPath: '/root/ci-project/-/commits/main', - tags: [], - shortSha: '2d5d8323', - commitPath: '/root/ci-project/-/commit/2d5d83230bdea0e003d83ef4c16d2bf9a8808ebe', - pipeline: { - id: 'gid://gitlab/Ci::Pipeline/424', - path: '/root/ci-project/-/pipelines/424', - user: { - webPath: '/root', - avatarUrl: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - __typename: 'User', - }, - __typename: 'Pipeline', - }, - stage: { name: 'deploy', __typename: 'CiStage' }, - name: 'artifact_job', - duration: 2, - finishedAt: '2021-04-01T17:36:18Z', - coverage: 82.71, - createdByTag: false, - retryable: true, - playable: false, - cancelable: false, - active: false, - stuck: false, - userPermissions: { readBuild: false, __typename: 'JobPermissions' }, - __typename: 'CiJob', - }, - { - artifacts: { nodes: [], __typename: 'CiJobArtifactConnection' }, - allowFailure: false, - status: 'PENDING', - scheduledAt: null, - manualJob: false, - triggered: null, - createdByTag: false, - detailedStatus: { - detailsPath: '/root/ci-project/-/jobs/2391', - group: 'pending', - icon: 'status_pending', - label: 'pending', - text: 'pending', - tooltip: 'pending', - action: { - buttonTitle: 'Cancel this job', - icon: 'cancel', - method: 'post', - path: '/root/ci-project/-/jobs/2391/cancel', - title: 'Cancel', - __typename: 'StatusAction', - }, - __typename: 'DetailedStatus', - }, - id: 'gid://gitlab/Ci::Build/2391', - refName: 'master', - refPath: '/root/ci-project/-/commits/master', - tags: [], - shortSha: '916330b4', - commitPath: '/root/ci-project/-/commit/916330b4fda5dae226524ceb51c756c0ed26679d', - pipeline: { - id: 'gid://gitlab/Ci::Pipeline/482', - path: '/root/ci-project/-/pipelines/482', - user: { - webPath: '/root', - avatarUrl: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - __typename: 'UserCore', - }, - __typename: 'Pipeline', - }, - stage: { name: 'build', __typename: 'CiStage' }, - name: 'build_job', - duration: null, - finishedAt: null, - coverage: null, - retryable: false, - playable: false, - cancelable: true, - active: true, - stuck: true, - userPermissions: { readBuild: true, __typename: 'JobPermissions' }, - __typename: 'CiJob', - }, -]; - -export const mockJobsQueryResponse = { - data: { - project: { - id: '1', - jobs: { - count: 1, - pageInfo: { - endCursor: 'eyJpZCI6IjIzMTcifQ', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'eyJpZCI6IjIzMzYifQ', - __typename: 'PageInfo', - }, - nodes: [ - { - artifacts: { - nodes: [ - { - downloadPath: '/root/ci-project/-/jobs/2336/artifacts/download?file_type=trace', - fileType: 'TRACE', - __typename: 'CiJobArtifact', - }, - { - downloadPath: - '/root/ci-project/-/jobs/2336/artifacts/download?file_type=metadata', - fileType: 'METADATA', - __typename: 'CiJobArtifact', - }, - { - downloadPath: '/root/ci-project/-/jobs/2336/artifacts/download?file_type=archive', - fileType: 'ARCHIVE', - __typename: 'CiJobArtifact', - }, - ], - __typename: 'CiJobArtifactConnection', - }, - allowFailure: false, - status: 'SUCCESS', - scheduledAt: null, - manualJob: false, - triggered: null, - createdByTag: false, - detailedStatus: { - id: 'status-1', - detailsPath: '/root/ci-project/-/jobs/2336', - group: 'success', - icon: 'status_success', - label: 'passed', - text: 'passed', - tooltip: 'passed', - action: { - id: 'action-1', - buttonTitle: 'Retry this job', - icon: 'retry', - method: 'post', - path: '/root/ci-project/-/jobs/2336/retry', - title: 'Retry', - __typename: 'StatusAction', - }, - __typename: 'DetailedStatus', - }, - id: 'gid://gitlab/Ci::Build/2336', - refName: 'main', - refPath: '/root/ci-project/-/commits/main', - tags: [], - shortSha: '4408fa2a', - commitPath: '/root/ci-project/-/commit/4408fa2a27aaadfdf42d8dda3d6a9c01ce6cad78', - pipeline: { - id: 'gid://gitlab/Ci::Pipeline/473', - path: '/root/ci-project/-/pipelines/473', - user: { - id: 'user-1', - webPath: '/root', - avatarUrl: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - __typename: 'UserCore', - }, - __typename: 'Pipeline', - }, - stage: { - id: 'stage-1', - name: 'deploy', - __typename: 'CiStage', - }, - name: 'artifact_job', - duration: 3, - finishedAt: '2021-04-29T14:19:50Z', - coverage: null, - retryable: true, - playable: false, - cancelable: false, - active: false, - stuck: false, - userPermissions: { - readBuild: true, - readJobArtifacts: true, - updateBuild: true, - __typename: 'JobPermissions', - }, - __typename: 'CiJob', - }, - ], - __typename: 'CiJobConnection', - }, - __typename: 'Project', - }, - }, -}; - -export const mockJobsQueryEmptyResponse = { - data: { - project: { - id: '1', - jobs: [], - }, - }, -}; - -export const retryableJob = { - artifacts: { - nodes: [ - { - downloadPath: '/root/ci-project/-/jobs/847/artifacts/download?file_type=trace', - fileType: 'TRACE', - __typename: 'CiJobArtifact', - }, - ], - __typename: 'CiJobArtifactConnection', - }, - allowFailure: false, - status: 'SUCCESS', - scheduledAt: null, - manualJob: false, - triggered: null, - createdByTag: false, - detailedStatus: { - detailsPath: '/root/test-job-artifacts/-/jobs/1981', - group: 'success', - icon: 'status_success', - label: 'passed', - text: 'passed', - tooltip: 'passed', - action: { - buttonTitle: 'Retry this job', - icon: 'retry', - method: 'post', - path: '/root/test-job-artifacts/-/jobs/1981/retry', - title: 'Retry', - __typename: 'StatusAction', - }, - __typename: 'DetailedStatus', - }, - id: 'gid://gitlab/Ci::Build/1981', - refName: 'main', - refPath: '/root/test-job-artifacts/-/commits/main', - tags: [], - shortSha: '75daf01b', - commitPath: '/root/test-job-artifacts/-/commit/75daf01b465e7eab5a04a315e44660c9a17c8055', - pipeline: { - id: 'gid://gitlab/Ci::Pipeline/288', - path: '/root/test-job-artifacts/-/pipelines/288', - user: { - webPath: '/root', - avatarUrl: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - __typename: 'UserCore', - }, - __typename: 'Pipeline', - }, - stage: { name: 'test', __typename: 'CiStage' }, - name: 'hello_world', - duration: 7, - finishedAt: '2021-08-30T20:33:56Z', - coverage: null, - retryable: true, - playable: false, - cancelable: false, - active: false, - stuck: false, - userPermissions: { readBuild: true, updateBuild: true, __typename: 'JobPermissions' }, - __typename: 'CiJob', -}; - -export const cancelableJob = { - artifacts: { - nodes: [], - __typename: 'CiJobArtifactConnection', - }, - allowFailure: false, - status: 'PENDING', - scheduledAt: null, - manualJob: false, - triggered: null, - createdByTag: false, - detailedStatus: { - id: 'pending-1305-1305', - detailsPath: '/root/lots-of-jobs-project/-/jobs/1305', - group: 'pending', - icon: 'status_pending', - label: 'pending', - text: 'pending', - tooltip: 'pending', - action: { - id: 'Ci::Build-pending-1305', - buttonTitle: 'Cancel this job', - icon: 'cancel', - method: 'post', - path: '/root/lots-of-jobs-project/-/jobs/1305/cancel', - title: 'Cancel', - __typename: 'StatusAction', - }, - __typename: 'DetailedStatus', - }, - id: 'gid://gitlab/Ci::Build/1305', - refName: 'main', - refPath: '/root/lots-of-jobs-project/-/commits/main', - tags: [], - shortSha: '750605f2', - commitPath: '/root/lots-of-jobs-project/-/commit/750605f29530778cf0912779eba6d073128962a5', - stage: { - id: 'gid://gitlab/Ci::Stage/181', - name: 'deploy', - __typename: 'CiStage', - }, - name: 'job_212', - duration: null, - finishedAt: null, - coverage: null, - retryable: false, - playable: false, - cancelable: true, - active: true, - stuck: false, - userPermissions: { - readBuild: true, - readJobArtifacts: true, - updateBuild: true, - __typename: 'JobPermissions', - }, - __typename: 'CiJob', -}; - -export const cannotRetryJob = { - ...retryableJob, - userPermissions: { readBuild: true, updateBuild: false, __typename: 'JobPermissions' }, -}; - -export const playableJob = { - artifacts: { - nodes: [ - { - downloadPath: '/root/ci-project/-/jobs/621/artifacts/download?file_type=archive', - fileType: 'ARCHIVE', - __typename: 'CiJobArtifact', - }, - { - downloadPath: '/root/ci-project/-/jobs/621/artifacts/download?file_type=metadata', - fileType: 'METADATA', - __typename: 'CiJobArtifact', - }, - { - downloadPath: '/root/ci-project/-/jobs/621/artifacts/download?file_type=trace', - fileType: 'TRACE', - __typename: 'CiJobArtifact', - }, - ], - __typename: 'CiJobArtifactConnection', - }, - allowFailure: false, - status: 'SUCCESS', - scheduledAt: null, - manualJob: true, - triggered: null, - createdByTag: false, - detailedStatus: { - detailsPath: '/root/test-job-artifacts/-/jobs/1982', - group: 'success', - icon: 'status_success', - label: 'manual play action', - text: 'passed', - tooltip: 'passed', - action: { - buttonTitle: 'Trigger this manual action', - icon: 'play', - method: 'post', - path: '/root/test-job-artifacts/-/jobs/1982/play', - title: 'Play', - __typename: 'StatusAction', - }, - __typename: 'DetailedStatus', - }, - id: 'gid://gitlab/Ci::Build/1982', - refName: 'main', - refPath: '/root/test-job-artifacts/-/commits/main', - tags: [], - shortSha: '75daf01b', - commitPath: '/root/test-job-artifacts/-/commit/75daf01b465e7eab5a04a315e44660c9a17c8055', - pipeline: { - id: 'gid://gitlab/Ci::Pipeline/288', - path: '/root/test-job-artifacts/-/pipelines/288', - user: { - webPath: '/root', - avatarUrl: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - __typename: 'UserCore', - }, - __typename: 'Pipeline', - }, - stage: { name: 'test', __typename: 'CiStage' }, - name: 'hello_world_delayed', - duration: 6, - finishedAt: '2021-08-30T20:36:12Z', - coverage: null, - retryable: true, - playable: true, - cancelable: false, - active: false, - stuck: false, - userPermissions: { - readBuild: true, - readJobArtifacts: true, - updateBuild: true, - __typename: 'JobPermissions', - }, - __typename: 'CiJob', -}; - -export const cannotPlayJob = { - ...playableJob, - userPermissions: { - readBuild: true, - readJobArtifacts: true, - updateBuild: false, - __typename: 'JobPermissions', - }, -}; - -export const scheduledJob = { - artifacts: { nodes: [], __typename: 'CiJobArtifactConnection' }, - allowFailure: false, - status: 'SCHEDULED', - scheduledAt: '2021-08-31T22:36:05Z', - manualJob: true, - triggered: null, - createdByTag: false, - detailedStatus: { - detailsPath: '/root/test-job-artifacts/-/jobs/1986', - group: 'scheduled', - icon: 'status_scheduled', - label: 'unschedule action', - text: 'delayed', - tooltip: 'delayed manual action (%{remainingTime})', - action: { - buttonTitle: 'Unschedule job', - icon: 'time-out', - method: 'post', - path: '/root/test-job-artifacts/-/jobs/1986/unschedule', - title: 'Unschedule', - __typename: 'StatusAction', - }, - __typename: 'DetailedStatus', - }, - id: 'gid://gitlab/Ci::Build/1986', - refName: 'main', - refPath: '/root/test-job-artifacts/-/commits/main', - tags: [], - shortSha: '75daf01b', - commitPath: '/root/test-job-artifacts/-/commit/75daf01b465e7eab5a04a315e44660c9a17c8055', - pipeline: { - id: 'gid://gitlab/Ci::Pipeline/290', - path: '/root/test-job-artifacts/-/pipelines/290', - user: { - webPath: '/root', - avatarUrl: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - __typename: 'UserCore', - }, - __typename: 'Pipeline', - }, - stage: { name: 'test', __typename: 'CiStage' }, - name: 'hello_world_delayed', - duration: null, - finishedAt: null, - coverage: null, - retryable: false, - playable: true, - cancelable: false, - active: false, - stuck: false, - userPermissions: { readBuild: true, updateBuild: true, __typename: 'JobPermissions' }, - __typename: 'CiJob', -}; - -export const cannotPlayScheduledJob = { - ...scheduledJob, - userPermissions: { - readBuild: true, - readJobArtifacts: true, - updateBuild: false, - __typename: 'JobPermissions', - }, -}; - export const CIJobConnectionIncomingCache = { __typename: 'CiJobConnection', pageInfo: { @@ -2000,3 +1414,167 @@ export const unscheduleMutationResponse = { }, }, }; + +export const mockJobLog = [ + { offset: 0, content: [{ text: 'Running with gitlab-runner 15.0.0 (febb2a09)' }], lineNumber: 0 }, + { offset: 54, content: [{ text: ' on colima-docker EwM9WzgD' }], lineNumber: 1 }, + { + isClosed: false, + isHeader: true, + line: { + offset: 91, + content: [{ text: 'Resolving secrets', style: 'term-fg-l-cyan term-bold' }], + section: 'resolve-secrets', + section_header: true, + lineNumber: 2, + section_duration: '00:00', + }, + lines: [], + }, + { + isClosed: false, + isHeader: true, + line: { + offset: 218, + content: [{ text: 'Preparing the "docker" executor', style: 'term-fg-l-cyan term-bold' }], + section: 'prepare-executor', + section_header: true, + lineNumber: 4, + section_duration: '00:01', + }, + lines: [ + { + offset: 317, + content: [{ text: 'Using Docker executor with image ruby:2.7 ...' }], + section: 'prepare-executor', + lineNumber: 5, + }, + { + offset: 372, + content: [{ text: 'Pulling docker image ruby:2.7 ...' }], + section: 'prepare-executor', + lineNumber: 6, + }, + { + offset: 415, + content: [ + { + text: + 'Using docker image sha256:55106bf6ba7f452c38d01ea760affc6ceb67d4b60068ffadab98d1b7b007668c for ruby:2.7 with digest ruby@sha256:23d08a4bae1a12ee3fce017f83204fcf9a02243443e4a516e65e5ff73810a449 ...', + }, + ], + section: 'prepare-executor', + lineNumber: 7, + }, + ], + }, + { + isClosed: false, + isHeader: true, + line: { + offset: 665, + content: [{ text: 'Preparing environment', style: 'term-fg-l-cyan term-bold' }], + section: 'prepare-script', + section_header: true, + lineNumber: 9, + section_duration: '00:01', + }, + lines: [ + { + offset: 752, + content: [ + { text: 'Running on runner-ewm9wzgd-project-20-concurrent-0 via 8ea689ec6969...' }, + ], + section: 'prepare-script', + lineNumber: 10, + }, + ], + }, + { + isClosed: false, + isHeader: true, + line: { + offset: 865, + content: [{ text: 'Getting source from Git repository', style: 'term-fg-l-cyan term-bold' }], + section: 'get-sources', + section_header: true, + lineNumber: 12, + section_duration: '00:01', + }, + lines: [ + { + offset: 962, + content: [ + { + text: 'Fetching changes with git depth set to 20...', + style: 'term-fg-l-green term-bold', + }, + ], + section: 'get-sources', + lineNumber: 13, + }, + { + offset: 1019, + content: [ + { text: 'Reinitialized existing Git repository in /builds/root/ci-project/.git/' }, + ], + section: 'get-sources', + lineNumber: 14, + }, + { + offset: 1090, + content: [{ text: 'Checking out e0f63d76 as main...', style: 'term-fg-l-green term-bold' }], + section: 'get-sources', + lineNumber: 15, + }, + { + offset: 1136, + content: [{ text: 'Skipping Git submodules setup', style: 'term-fg-l-green term-bold' }], + section: 'get-sources', + lineNumber: 16, + }, + ], + }, + { + isClosed: false, + isHeader: true, + line: { + offset: 1217, + content: [ + { + text: 'Executing "step_script" stage of the job script', + style: 'term-fg-l-cyan term-bold', + }, + ], + section: 'step-script', + section_header: true, + lineNumber: 18, + section_duration: '00:00', + }, + lines: [ + { + offset: 1327, + content: [ + { + text: + 'Using docker image sha256:55106bf6ba7f452c38d01ea760affc6ceb67d4b60068ffadab98d1b7b007668c for ruby:2.7 with digest ruby@sha256:23d08a4bae1a12ee3fce017f83204fcf9a02243443e4a516e65e5ff73810a449 ...', + }, + ], + section: 'step-script', + lineNumber: 19, + }, + { + offset: 1533, + content: [{ text: '$ echo "82.71"', style: 'term-fg-l-green term-bold' }], + section: 'step-script', + lineNumber: 20, + }, + { offset: 1560, content: [{ text: '82.71' }], section: 'step-script', lineNumber: 21 }, + ], + }, + { + offset: 1605, + content: [{ text: 'Job succeeded', style: 'term-fg-l-green term-bold' }], + lineNumber: 23, + }, +]; diff --git a/spec/frontend/jobs/store/mutations_spec.js b/spec/frontend/jobs/store/mutations_spec.js index b73aa8abf4e..ea1ec383d6e 100644 --- a/spec/frontend/jobs/store/mutations_spec.js +++ b/spec/frontend/jobs/store/mutations_spec.js @@ -4,21 +4,12 @@ import state from '~/jobs/store/state'; describe('Jobs Store Mutations', () => { let stateCopy; - let origGon; const html = 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- : Writing /builds/ab89e95b0fa0b9272ea0c797b76908f24d36992630e9325273a4ce3.png<br>I'; beforeEach(() => { stateCopy = state(); - - origGon = window.gon; - - window.gon = { features: { infinitelyCollapsibleSections: false } }; - }); - - afterEach(() => { - window.gon = origGon; }); describe('SET_JOB_ENDPOINT', () => { @@ -276,88 +267,3 @@ describe('Jobs Store Mutations', () => { }); }); }); - -describe('Job Store mutations, feature flag ON', () => { - let stateCopy; - let origGon; - - const html = - 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- : Writing /builds/ab89e95b0fa0b9272ea0c797b76908f24d36992630e9325273a4ce3.png<br>I'; - - beforeEach(() => { - stateCopy = state(); - - origGon = window.gon; - - window.gon = { features: { infinitelyCollapsibleSections: true } }; - }); - - afterEach(() => { - window.gon = origGon; - }); - - describe('RECEIVE_JOB_LOG_SUCCESS', () => { - describe('with new job log', () => { - describe('log.lines', () => { - describe('when append is true', () => { - it('sets the parsed log ', () => { - mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { - append: true, - size: 511846, - complete: true, - lines: [ - { - offset: 1, - content: [{ text: 'Running with gitlab-runner 11.12.1 (5a147c92)' }], - }, - ], - }); - - expect(stateCopy.jobLog).toEqual([ - { - offset: 1, - content: [{ text: 'Running with gitlab-runner 11.12.1 (5a147c92)' }], - lineNumber: 1, - }, - ]); - }); - }); - - describe('when lines are defined', () => { - it('sets the parsed log ', () => { - mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { - append: false, - size: 511846, - complete: true, - lines: [ - { offset: 0, content: [{ text: 'Running with gitlab-runner 11.11.1 (5a147c92)' }] }, - ], - }); - - expect(stateCopy.jobLog).toEqual([ - { - offset: 0, - content: [{ text: 'Running with gitlab-runner 11.11.1 (5a147c92)' }], - lineNumber: 1, - }, - ]); - }); - }); - - describe('when lines are null', () => { - it('sets the default value', () => { - mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { - append: true, - html, - size: 511846, - complete: false, - lines: null, - }); - - expect(stateCopy.jobLog).toEqual([]); - }); - }); - }); - }); - }); -}); diff --git a/spec/frontend/jobs/store/utils_spec.js b/spec/frontend/jobs/store/utils_spec.js index 92ac33c8792..9458c2184f5 100644 --- a/spec/frontend/jobs/store/utils_spec.js +++ b/spec/frontend/jobs/store/utils_spec.js @@ -1,6 +1,5 @@ import { logLinesParser, - logLinesParserLegacy, updateIncrementalJobLog, parseHeaderLine, parseLine, @@ -18,8 +17,6 @@ import { headerTraceIncremental, collapsibleTrace, collapsibleTraceIncremental, - multipleCollapsibleSectionsMockData, - backwardsCompatibilityTrace, } from '../components/log/mock_data'; describe('Jobs Store Utils', () => { @@ -178,11 +175,11 @@ describe('Jobs Store Utils', () => { expect(isCollapsibleSection()).toEqual(false); }); }); - describe('logLinesParserLegacy', () => { + describe('logLinesParser', () => { let result; beforeEach(() => { - result = logLinesParserLegacy(utilsMockData); + result = logLinesParser(utilsMockData); }); describe('regular line', () => { @@ -219,102 +216,6 @@ describe('Jobs Store Utils', () => { }); }); - describe('logLinesParser', () => { - let result; - - beforeEach(() => { - result = logLinesParser(utilsMockData); - }); - - describe('regular line', () => { - it('adds a lineNumber property with correct index', () => { - expect(result.parsedLines[0].lineNumber).toEqual(1); - expect(result.parsedLines[1].line.lineNumber).toEqual(2); - }); - }); - - describe('collapsible section', () => { - it('adds a `isClosed` property', () => { - expect(result.parsedLines[1].isClosed).toEqual(false); - }); - - it('adds a `isHeader` property', () => { - expect(result.parsedLines[1].isHeader).toEqual(true); - }); - - it('creates a lines array property with the content of the collapsible section', () => { - expect(result.parsedLines[1].lines.length).toEqual(2); - expect(result.parsedLines[1].lines[0].content).toEqual(utilsMockData[2].content); - expect(result.parsedLines[1].lines[1].content).toEqual(utilsMockData[3].content); - }); - }); - - describe('section duration', () => { - it('adds the section information to the header section', () => { - expect(result.parsedLines[1].line.section_duration).toEqual( - utilsMockData[4].section_duration, - ); - }); - - it('does not add section duration as a line', () => { - expect(result.parsedLines[1].lines.includes(utilsMockData[4])).toEqual(false); - }); - }); - - describe('multiple collapsible sections', () => { - beforeEach(() => { - result = logLinesParser(multipleCollapsibleSectionsMockData); - }); - - it('should contain a section inside another section', () => { - const innerSection = [ - { - isClosed: false, - isHeader: true, - line: { - content: [{ text: '1st collapsible section' }], - lineNumber: 6, - offset: 1006, - section: 'collapsible-1', - section_duration: '01:00', - section_header: true, - }, - lines: [ - { - content: [ - { - text: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam lorem dolor, congue ac condimentum vitae', - }, - ], - lineNumber: 7, - offset: 1007, - section: 'collapsible-1', - }, - ], - }, - ]; - - expect(result.parsedLines[1].lines).toEqual(expect.arrayContaining(innerSection)); - }); - }); - - describe('backwards compatibility', () => { - beforeEach(() => { - result = logLinesParser(backwardsCompatibilityTrace); - }); - - it('should return an object with a parsedLines prop', () => { - expect(result).toEqual( - expect.objectContaining({ - parsedLines: expect.any(Array), - }), - ); - expect(result.parsedLines).toHaveLength(1); - }); - }); - }); - describe('findOffsetAndRemove', () => { describe('when last item is header', () => { const existingLog = [ @@ -490,7 +391,7 @@ describe('Jobs Store Utils', () => { describe('updateIncrementalJobLog', () => { describe('without repeated section', () => { it('concats and parses both arrays', () => { - const oldLog = logLinesParserLegacy(originalTrace); + const oldLog = logLinesParser(originalTrace); const result = updateIncrementalJobLog(regularIncremental, oldLog); expect(result).toEqual([ @@ -518,7 +419,7 @@ describe('Jobs Store Utils', () => { describe('with regular line repeated offset', () => { it('updates the last line and formats with the incremental part', () => { - const oldLog = logLinesParserLegacy(originalTrace); + const oldLog = logLinesParser(originalTrace); const result = updateIncrementalJobLog(regularIncrementalRepeated, oldLog); expect(result).toEqual([ @@ -537,7 +438,7 @@ describe('Jobs Store Utils', () => { describe('with header line repeated', () => { it('updates the header line and formats with the incremental part', () => { - const oldLog = logLinesParserLegacy(headerTrace); + const oldLog = logLinesParser(headerTrace); const result = updateIncrementalJobLog(headerTraceIncremental, oldLog); expect(result).toEqual([ @@ -563,7 +464,7 @@ describe('Jobs Store Utils', () => { describe('with collapsible line repeated', () => { it('updates the collapsible line and formats with the incremental part', () => { - const oldLog = logLinesParserLegacy(collapsibleTrace); + const oldLog = logLinesParser(collapsibleTrace); const result = updateIncrementalJobLog(collapsibleTraceIncremental, oldLog); expect(result).toEqual([ diff --git a/spec/frontend/lib/dompurify_spec.js b/spec/frontend/lib/dompurify_spec.js index 34325dad6a1..b585c69e911 100644 --- a/spec/frontend/lib/dompurify_spec.js +++ b/spec/frontend/lib/dompurify_spec.js @@ -34,6 +34,17 @@ const unsafeUrls = [ `${absoluteGon.sprite_file_icons}/../../https://evil.url`, ]; +/* eslint-disable no-script-url */ +const invalidProtocolUrls = [ + 'javascript:alert(1)', + 'jAvascript:alert(1)', + 'data:text/html,<script>alert(1);</script>', + ' javascript:', + 'javascript :', +]; +/* eslint-enable no-script-url */ +const validProtocolUrls = ['slack://open', 'x-devonthink-item://90909', 'x-devonthink-item:90909']; + const forbiddenDataAttrs = ['data-remote', 'data-url', 'data-type', 'data-method']; const acceptedDataAttrs = ['data-random', 'data-custom']; @@ -150,4 +161,16 @@ describe('~/lib/dompurify', () => { expect(sanitize(htmlHref)).toBe(`<a ${attrWithValue}>hello</a>`); }); }); + + describe('with non-http links', () => { + it.each(validProtocolUrls)('should allow %s', (url) => { + const html = `<a href="${url}">internal link</a>`; + expect(sanitize(html)).toBe(`<a href="${url}">internal link</a>`); + }); + + it.each(invalidProtocolUrls)('should not allow %s', (url) => { + const html = `<a href="${url}">internal link</a>`; + expect(sanitize(html)).toBe(`<a>internal link</a>`); + }); + }); }); diff --git a/spec/frontend/lib/gfm/index_spec.js b/spec/frontend/lib/gfm/index_spec.js index 7aab0072364..b722315d63a 100644 --- a/spec/frontend/lib/gfm/index_spec.js +++ b/spec/frontend/lib/gfm/index_spec.js @@ -1,11 +1,12 @@ import { render } from '~/lib/gfm'; describe('gfm', () => { - const markdownToAST = async (markdown) => { + const markdownToAST = async (markdown, skipRendering = []) => { let result; await render({ markdown, + skipRendering, renderer: (tree) => { result = tree; }, @@ -58,36 +59,62 @@ describe('gfm', () => { expect(result).toEqual(rendered); }); - it('transforms footnotes into footnotedefinition and footnotereference tags', async () => { - const result = await markdownToAST( - `footnote reference [^footnote] + describe('when skipping the rendering of footnote reference and definition nodes', () => { + it('transforms footnotes into footnotedefinition and footnotereference tags', async () => { + const result = await markdownToAST( + `footnote reference [^footnote] [^footnote]: Footnote definition`, - ); + ['footnoteReference', 'footnoteDefinition'], + ); - expectInRoot( - result, - expect.objectContaining({ - children: expect.arrayContaining([ - expect.objectContaining({ - type: 'element', - tagName: 'footnotereference', - properties: { - identifier: 'footnote', - label: 'footnote', - }, - }), - ]), - }), + expectInRoot( + result, + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + type: 'element', + tagName: 'footnotereference', + properties: { + identifier: 'footnote', + label: 'footnote', + }, + }), + ]), + }), + ); + + expectInRoot( + result, + expect.objectContaining({ + tagName: 'footnotedefinition', + properties: { + identifier: 'footnote', + label: 'footnote', + }, + }), + ); + }); + }); + }); + + describe('when skipping the rendering of code blocks', () => { + it('transforms code nodes into codeblock html tags', async () => { + const result = await markdownToAST( + ` +\`\`\`javascript +console.log('Hola'); +\`\`\`\ + `, + ['code'], ); expectInRoot( result, expect.objectContaining({ - tagName: 'footnotedefinition', + tagName: 'codeblock', properties: { - identifier: 'footnote', - label: 'footnote', + language: 'javascript', }, }), ); diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js index 8e499844406..7cf101a5e59 100644 --- a/spec/frontend/lib/utils/common_utils_spec.js +++ b/spec/frontend/lib/utils/common_utils_spec.js @@ -88,6 +88,28 @@ describe('common_utils', () => { expectGetElementIdToHaveBeenCalledWith('user-content-definição'); }); + it(`does not scroll when ${commonUtils.NO_SCROLL_TO_HASH_CLASS} is set on target`, () => { + jest.spyOn(window, 'scrollBy'); + + document.body.innerHTML += ` + <div id="parent"> + <a href="#test">Link</a> + <div style="height: 2000px;"></div> + <div id="test" style="height: 2000px;" class="${commonUtils.NO_SCROLL_TO_HASH_CLASS}"></div> + </div> + `; + + window.history.pushState({}, null, '#test'); + commonUtils.handleLocationHash(); + jest.runOnlyPendingTimers(); + + try { + expect(window.scrollBy).not.toHaveBeenCalled(); + } finally { + document.getElementById('parent').remove(); + } + }); + it('scrolls element into view', () => { document.body.innerHTML += ` <div id="parent"> diff --git a/spec/frontend/lib/utils/navigation_utility_spec.js b/spec/frontend/lib/utils/navigation_utility_spec.js index 632a8904578..6d3a871eb33 100644 --- a/spec/frontend/lib/utils/navigation_utility_spec.js +++ b/spec/frontend/lib/utils/navigation_utility_spec.js @@ -81,8 +81,6 @@ describe('initPrefetchLinks', () => { const mouseOverEvent = new Event('mouseover'); beforeEach(() => { - jest.useFakeTimers(); - jest.spyOn(global, 'setTimeout'); jest.spyOn(newLink, 'removeEventListener'); }); diff --git a/spec/frontend/lib/utils/rails_ujs_spec.js b/spec/frontend/lib/utils/rails_ujs_spec.js index 00c29b72e73..c10301523c9 100644 --- a/spec/frontend/lib/utils/rails_ujs_spec.js +++ b/spec/frontend/lib/utils/rails_ujs_spec.js @@ -8,7 +8,7 @@ beforeAll(async () => { // that jQuery isn't available *before* we import @rails/ujs. delete global.jQuery; - const { initRails } = await import('~/lib/utils/rails_ujs.js'); + const { initRails } = await import('~/lib/utils/rails_ujs'); initRails(); }); diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js index 9570d2a831c..8e31fc792c5 100644 --- a/spec/frontend/lib/utils/text_utility_spec.js +++ b/spec/frontend/lib/utils/text_utility_spec.js @@ -384,4 +384,17 @@ describe('text_utility', () => { ); }); }); + + describe('limitedCounterWithDelimiter', () => { + it('returns 1,000+ for count greater than 1000', () => { + const expectedOutput = '1,000+'; + + expect(textUtils.limitedCounterWithDelimiter(1001)).toBe(expectedOutput); + expect(textUtils.limitedCounterWithDelimiter(2300)).toBe(expectedOutput); + }); + + it('returns exact number for count less than 1000', () => { + expect(textUtils.limitedCounterWithDelimiter(120)).toBe(120); + }); + }); }); diff --git a/spec/frontend/logs/components/environment_logs_spec.js b/spec/frontend/logs/components/environment_logs_spec.js deleted file mode 100644 index 84dc0bdf6cd..00000000000 --- a/spec/frontend/logs/components/environment_logs_spec.js +++ /dev/null @@ -1,370 +0,0 @@ -import { GlSprintf, GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { scrollDown } from '~/lib/utils/scroll_utils'; -import EnvironmentLogs from '~/logs/components/environment_logs.vue'; - -import { createStore } from '~/logs/stores'; -import { - mockEnvName, - mockEnvironments, - mockPods, - mockLogsResult, - mockTrace, - mockEnvironmentsEndpoint, - mockDocumentationPath, -} from '../mock_data'; - -jest.mock('~/lib/utils/scroll_utils'); - -const module = 'environmentLogs'; - -jest.mock('lodash/throttle', () => - jest.fn((func) => { - return func; - }), -); - -describe('EnvironmentLogs', () => { - let store; - let dispatch; - let wrapper; - let state; - - const propsData = { - environmentName: mockEnvName, - environmentsPath: mockEnvironmentsEndpoint, - clusterApplicationsDocumentationPath: mockDocumentationPath, - clustersPath: '/gitlab-org', - }; - - const updateControlBtnsMock = jest.fn(); - const LogControlButtonsStub = { - template: '<div/>', - methods: { - update: updateControlBtnsMock, - }, - props: { - scrollDownButtonDisabled: false, - }, - }; - - const findEnvironmentsDropdown = () => wrapper.find('.js-environments-dropdown'); - - const findSimpleFilters = () => wrapper.find({ ref: 'log-simple-filters' }); - const findAdvancedFilters = () => wrapper.find({ ref: 'log-advanced-filters' }); - const findElasticsearchNotice = () => wrapper.find({ ref: 'elasticsearchNotice' }); - const findLogControlButtons = () => wrapper.find(LogControlButtonsStub); - - const findInfiniteScroll = () => wrapper.find({ ref: 'infiniteScroll' }); - const findLogTrace = () => wrapper.find({ ref: 'logTrace' }); - const findLogFooter = () => wrapper.find({ ref: 'logFooter' }); - const getInfiniteScrollAttr = (attr) => parseInt(findInfiniteScroll().attributes(attr), 10); - - const mockSetInitData = () => { - state.pods.options = mockPods; - state.environments.current = mockEnvName; - [state.pods.current] = state.pods.options; - - state.logs.lines = []; - }; - - const mockShowPodLogs = () => { - state.pods.options = mockPods; - [state.pods.current] = mockPods; - - state.logs.lines = mockLogsResult; - }; - - const mockFetchEnvs = () => { - state.environments.options = mockEnvironments; - }; - - const initWrapper = () => { - wrapper = shallowMount(EnvironmentLogs, { - propsData, - store, - stubs: { - LogControlButtons: LogControlButtonsStub, - GlInfiniteScroll: { - name: 'gl-infinite-scroll', - template: ` - <div> - <slot name="header"></slot> - <slot name="items"></slot> - <slot></slot> - </div> - `, - }, - GlSprintf, - }, - }); - }; - - beforeEach(() => { - store = createStore(); - state = store.state.environmentLogs; - - jest.spyOn(store, 'dispatch').mockResolvedValue(); - - dispatch = store.dispatch; - }); - - afterEach(() => { - store.dispatch.mockReset(); - - if (wrapper) { - wrapper.destroy(); - } - }); - - it('displays UI elements', () => { - initWrapper(); - - expect(findEnvironmentsDropdown().is(GlDropdown)).toBe(true); - expect(findSimpleFilters().exists()).toBe(true); - expect(findLogControlButtons().exists()).toBe(true); - - expect(findInfiniteScroll().exists()).toBe(true); - expect(findLogTrace().exists()).toBe(true); - }); - - it('mounted inits data', () => { - initWrapper(); - - expect(dispatch).toHaveBeenCalledWith(`${module}/setInitData`, { - timeRange: expect.objectContaining({ - default: true, - }), - environmentName: mockEnvName, - podName: null, - }); - - expect(dispatch).toHaveBeenCalledWith(`${module}/fetchEnvironments`, mockEnvironmentsEndpoint); - }); - - describe('loading state', () => { - beforeEach(() => { - state.pods.options = []; - - state.logs.lines = []; - state.logs.isLoading = true; - - state.environments = { - options: [], - isLoading: true, - }; - - initWrapper(); - }); - - it('does not display an alert to upgrade to ES', () => { - expect(findElasticsearchNotice().exists()).toBe(false); - }); - - it('displays a disabled environments dropdown', () => { - expect(findEnvironmentsDropdown().attributes('disabled')).toBe('true'); - expect(findEnvironmentsDropdown().findAll(GlDropdownItem).length).toBe(0); - }); - - it('does not update buttons state', () => { - expect(updateControlBtnsMock).not.toHaveBeenCalled(); - }); - - it('shows an infinite scroll with no content', () => { - expect(getInfiniteScrollAttr('fetched-items')).toBe(0); - }); - - it('shows an infinite scroll container with no set max-height ', () => { - expect(findInfiniteScroll().attributes('max-list-height')).toBeUndefined(); - }); - - it('shows a logs trace', () => { - expect(findLogTrace().text()).toBe(''); - expect(findLogTrace().find('.js-build-loader-animation').isVisible()).toBe(true); - }); - }); - - describe('k8s environment', () => { - beforeEach(() => { - state.pods.options = []; - - state.logs.lines = []; - state.logs.isLoading = false; - - state.environments = { - options: mockEnvironments, - current: 'staging', - isLoading: false, - }; - - initWrapper(); - }); - - it('displays an alert to upgrade to ES', () => { - expect(findElasticsearchNotice().exists()).toBe(true); - }); - - it('displays simple filters for kubernetes logs API', () => { - expect(findSimpleFilters().exists()).toBe(true); - expect(findAdvancedFilters().exists()).toBe(false); - }); - }); - - describe('state with data', () => { - beforeEach(() => { - dispatch.mockImplementation((actionName) => { - if (actionName === `${module}/setInitData`) { - mockSetInitData(); - } else if (actionName === `${module}/showPodLogs`) { - mockShowPodLogs(); - } else if (actionName === `${module}/fetchEnvironments`) { - mockFetchEnvs(); - mockShowPodLogs(); - } - }); - - initWrapper(); - }); - - afterEach(() => { - scrollDown.mockReset(); - updateControlBtnsMock.mockReset(); - }); - - it('does not display an alert to upgrade to ES', () => { - expect(findElasticsearchNotice().exists()).toBe(false); - }); - - it('populates environments dropdown', () => { - const items = findEnvironmentsDropdown().findAll(GlDropdownItem); - expect(findEnvironmentsDropdown().props('text')).toBe(mockEnvName); - expect(items.length).toBe(mockEnvironments.length); - mockEnvironments.forEach((env, i) => { - const item = items.at(i); - expect(item.text()).toBe(env.name); - }); - }); - - it('dropdown has one environment selected', () => { - const items = findEnvironmentsDropdown().findAll(GlDropdownItem); - mockEnvironments.forEach((env, i) => { - const item = items.at(i); - - if (item.text() !== mockEnvName) { - expect(item.find(GlDropdownItem).attributes('ischecked')).toBeFalsy(); - } else { - expect(item.find(GlDropdownItem).attributes('ischecked')).toBeTruthy(); - } - }); - }); - - it('displays advanced filters for elasticsearch logs API', () => { - expect(findSimpleFilters().exists()).toBe(false); - expect(findAdvancedFilters().exists()).toBe(true); - }); - - it('shows infinite scroll with content', () => { - expect(getInfiniteScrollAttr('fetched-items')).toBe(mockTrace.length); - }); - - it('populates logs trace', () => { - const trace = findLogTrace(); - expect(trace.text().split('\n').length).toBe(mockTrace.length); - expect(trace.text().split('\n')).toEqual(mockTrace); - }); - - it('populates footer', () => { - const footer = findLogFooter().text(); - - expect(footer).toContain(`${mockLogsResult.length} results`); - }); - - describe('when user clicks', () => { - it('environment name, trace is refreshed', () => { - const items = findEnvironmentsDropdown().findAll(GlDropdownItem); - const index = 1; // any env - - expect(dispatch).not.toHaveBeenCalledWith(`${module}/showEnvironment`, expect.anything()); - - items.at(index).vm.$emit('click'); - - expect(dispatch).toHaveBeenCalledWith( - `${module}/showEnvironment`, - mockEnvironments[index].name, - ); - }); - - it('refresh button, trace is refreshed', () => { - expect(dispatch).not.toHaveBeenCalledWith(`${module}/refreshPodLogs`, undefined); - - findLogControlButtons().vm.$emit('refresh'); - - expect(dispatch).toHaveBeenCalledWith(`${module}/refreshPodLogs`, undefined); - }); - }); - }); - - describe('listeners', () => { - beforeEach(() => { - initWrapper(); - }); - - it('attaches listeners in components', () => { - expect(findInfiniteScroll().vm.$listeners).toEqual({ - topReached: expect.any(Function), - scroll: expect.any(Function), - }); - }); - - it('`topReached` when not loading', () => { - expect(store.dispatch).not.toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined); - - findInfiniteScroll().vm.$emit('topReached'); - - expect(store.dispatch).toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined); - }); - - it('`topReached` does not fetches more logs when already loading', () => { - state.logs.isLoading = true; - findInfiniteScroll().vm.$emit('topReached'); - - expect(store.dispatch).not.toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined); - }); - - it('`topReached` fetches more logs', () => { - state.logs.isLoading = true; - findInfiniteScroll().vm.$emit('topReached'); - - expect(store.dispatch).not.toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined); - }); - - it('`scroll` on a scrollable target results in enabled scroll buttons', async () => { - const target = { scrollTop: 10, clientHeight: 10, scrollHeight: 21 }; - - state.logs.isLoading = true; - findInfiniteScroll().vm.$emit('scroll', { target }); - - await nextTick(); - expect(findLogControlButtons().props('scrollDownButtonDisabled')).toEqual(false); - }); - - it('`scroll` on a non-scrollable target in disabled scroll buttons', async () => { - const target = { scrollTop: 10, clientHeight: 10, scrollHeight: 20 }; - - state.logs.isLoading = true; - findInfiniteScroll().vm.$emit('scroll', { target }); - - await nextTick(); - expect(findLogControlButtons().props('scrollDownButtonDisabled')).toEqual(true); - }); - - it('`scroll` on no target results in disabled scroll buttons', async () => { - state.logs.isLoading = true; - findInfiniteScroll().vm.$emit('scroll', { target: undefined }); - - await nextTick(); - expect(findLogControlButtons().props('scrollDownButtonDisabled')).toEqual(true); - }); - }); -}); diff --git a/spec/frontend/logs/components/log_advanced_filters_spec.js b/spec/frontend/logs/components/log_advanced_filters_spec.js deleted file mode 100644 index 4e4052eb4d8..00000000000 --- a/spec/frontend/logs/components/log_advanced_filters_spec.js +++ /dev/null @@ -1,175 +0,0 @@ -import { GlFilteredSearch } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { convertToFixedRange } from '~/lib/utils/datetime_range'; -import LogAdvancedFilters from '~/logs/components/log_advanced_filters.vue'; -import { TOKEN_TYPE_POD_NAME } from '~/logs/constants'; -import { createStore } from '~/logs/stores'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; -import { defaultTimeRange } from '~/vue_shared/constants'; -import { mockPods, mockSearch } from '../mock_data'; - -const module = 'environmentLogs'; - -describe('LogAdvancedFilters', () => { - let store; - let dispatch; - let wrapper; - let state; - - const findFilteredSearch = () => wrapper.find(GlFilteredSearch); - const findTimeRangePicker = () => wrapper.find({ ref: 'dateTimePicker' }); - const getSearchToken = (type) => - findFilteredSearch() - .props('availableTokens') - .filter((token) => token.type === type)[0]; - - const mockStateLoading = () => { - state.timeRange.selected = defaultTimeRange; - state.timeRange.current = convertToFixedRange(defaultTimeRange); - state.pods.options = []; - state.pods.current = null; - state.logs.isLoading = true; - }; - - const mockStateWithData = () => { - state.timeRange.selected = defaultTimeRange; - state.timeRange.current = convertToFixedRange(defaultTimeRange); - state.pods.options = mockPods; - state.pods.current = null; - state.logs.isLoading = false; - }; - - const initWrapper = (propsData = {}) => { - wrapper = shallowMount(LogAdvancedFilters, { - propsData: { - ...propsData, - }, - store, - }); - }; - - beforeEach(() => { - store = createStore(); - state = store.state.environmentLogs; - - jest.spyOn(store, 'dispatch').mockResolvedValue(); - - dispatch = store.dispatch; - }); - - afterEach(() => { - store.dispatch.mockReset(); - - if (wrapper) { - wrapper.destroy(); - } - }); - - it('displays UI elements', () => { - initWrapper(); - - expect(findFilteredSearch().exists()).toBe(true); - expect(findTimeRangePicker().exists()).toBe(true); - }); - - it('displays search tokens', () => { - initWrapper(); - - expect(getSearchToken(TOKEN_TYPE_POD_NAME)).toMatchObject({ - title: 'Pod name', - unique: true, - operators: OPERATOR_IS_ONLY, - }); - }); - - describe('disabled state', () => { - beforeEach(() => { - mockStateLoading(); - initWrapper({ - disabled: true, - }); - }); - - it('displays disabled filters', () => { - expect(findFilteredSearch().attributes('disabled')).toBeTruthy(); - expect(findTimeRangePicker().attributes('disabled')).toBeTruthy(); - }); - }); - - describe('when the state is loading', () => { - beforeEach(() => { - mockStateLoading(); - initWrapper(); - }); - - it('displays a disabled search', () => { - expect(findFilteredSearch().attributes('disabled')).toBeTruthy(); - }); - - it('displays an enable date filter', () => { - expect(findTimeRangePicker().attributes('disabled')).toBeFalsy(); - }); - - it('displays no pod options when no pods are available, so suggestions can be displayed', () => { - expect(getSearchToken(TOKEN_TYPE_POD_NAME).options).toBe(null); - expect(getSearchToken(TOKEN_TYPE_POD_NAME).loading).toBe(true); - }); - }); - - describe('when the state has data', () => { - beforeEach(() => { - mockStateWithData(); - initWrapper(); - }); - - it('displays a single token for pods', () => { - initWrapper(); - - const tokens = findFilteredSearch().props('availableTokens'); - - expect(tokens).toHaveLength(1); - expect(tokens[0].type).toBe(TOKEN_TYPE_POD_NAME); - }); - - it('displays a enabled filters', () => { - expect(findFilteredSearch().attributes('disabled')).toBeFalsy(); - expect(findTimeRangePicker().attributes('disabled')).toBeFalsy(); - }); - - it('displays options in the pods token', () => { - const { options } = getSearchToken(TOKEN_TYPE_POD_NAME); - - expect(options).toHaveLength(mockPods.length); - }); - - it('displays options in date time picker', () => { - const options = findTimeRangePicker().props('options'); - - expect(options).toEqual(expect.any(Array)); - expect(options.length).toBeGreaterThan(0); - }); - - describe('when the user interacts', () => { - it('clicks on the search button, showFilteredLogs is dispatched', () => { - findFilteredSearch().vm.$emit('submit', null); - - expect(dispatch).toHaveBeenCalledWith(`${module}/showFilteredLogs`, null); - }); - - it('clicks on the search button, showFilteredLogs is dispatched with null', () => { - findFilteredSearch().vm.$emit('submit', [mockSearch]); - - expect(dispatch).toHaveBeenCalledWith(`${module}/showFilteredLogs`, [mockSearch]); - }); - - it('selects a new time range', () => { - expect(findTimeRangePicker().attributes('disabled')).toBeFalsy(); - - const mockRange = { start: 'START_DATE', end: 'END_DATE' }; - findTimeRangePicker().vm.$emit('input', mockRange); - - expect(dispatch).toHaveBeenCalledWith(`${module}/setTimeRange`, mockRange); - }); - }); - }); -}); diff --git a/spec/frontend/logs/components/log_control_buttons_spec.js b/spec/frontend/logs/components/log_control_buttons_spec.js deleted file mode 100644 index e249272b87d..00000000000 --- a/spec/frontend/logs/components/log_control_buttons_spec.js +++ /dev/null @@ -1,88 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import LogControlButtons from '~/logs/components/log_control_buttons.vue'; - -describe('LogControlButtons', () => { - let wrapper; - - const findScrollToTop = () => wrapper.find('.js-scroll-to-top'); - const findScrollToBottom = () => wrapper.find('.js-scroll-to-bottom'); - const findRefreshBtn = () => wrapper.find('.js-refresh-log'); - - const initWrapper = (opts) => { - wrapper = shallowMount(LogControlButtons, { - listeners: { - scrollUp: () => {}, - scrollDown: () => {}, - }, - ...opts, - }); - }; - - afterEach(() => { - if (wrapper) { - wrapper.destroy(); - } - }); - - it('displays UI elements', () => { - initWrapper(); - - expect(findScrollToTop().is(GlButton)).toBe(true); - expect(findScrollToBottom().is(GlButton)).toBe(true); - expect(findRefreshBtn().is(GlButton)).toBe(true); - }); - - it('emits a `refresh` event on click on `refresh` button', async () => { - initWrapper(); - - // An `undefined` value means no event was emitted - expect(wrapper.emitted('refresh')).toBe(undefined); - - findRefreshBtn().vm.$emit('click'); - - await nextTick(); - expect(wrapper.emitted('refresh')).toHaveLength(1); - }); - - describe('when scrolling actions are enabled', () => { - beforeEach(async () => { - // mock scrolled to the middle of a long page - initWrapper(); - await nextTick(); - }); - - it('click on "scroll to top" scrolls up', () => { - expect(findScrollToTop().attributes('disabled')).toBeUndefined(); - - findScrollToTop().vm.$emit('click'); - - expect(wrapper.emitted('scrollUp')).toHaveLength(1); - }); - - it('click on "scroll to bottom" scrolls down', () => { - expect(findScrollToBottom().attributes('disabled')).toBeUndefined(); - - findScrollToBottom().vm.$emit('click'); - - expect(wrapper.emitted('scrollDown')).toHaveLength(1); - }); - }); - - describe('when scrolling actions are disabled', () => { - beforeEach(async () => { - initWrapper({ listeners: {} }); - await nextTick(); - }); - - it('buttons are disabled', async () => { - await nextTick(); - expect(findScrollToTop().exists()).toBe(false); - expect(findScrollToBottom().exists()).toBe(false); - // This should be enabled when gitlab-ui contains: - // https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1149 - // expect(findScrollToBottom().is('[disabled]')).toBe(true); - }); - }); -}); diff --git a/spec/frontend/logs/components/log_simple_filters_spec.js b/spec/frontend/logs/components/log_simple_filters_spec.js deleted file mode 100644 index 04ad2e03542..00000000000 --- a/spec/frontend/logs/components/log_simple_filters_spec.js +++ /dev/null @@ -1,134 +0,0 @@ -import { GlDropdownItem } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import LogSimpleFilters from '~/logs/components/log_simple_filters.vue'; -import { createStore } from '~/logs/stores'; -import { mockPods, mockPodName } from '../mock_data'; - -const module = 'environmentLogs'; - -describe('LogSimpleFilters', () => { - let store; - let dispatch; - let wrapper; - let state; - - const findPodsDropdown = () => wrapper.find({ ref: 'podsDropdown' }); - const findPodsNoPodsText = () => wrapper.find({ ref: 'noPodsMsg' }); - const findPodsDropdownItems = () => - findPodsDropdown() - .findAll(GlDropdownItem) - .filter((item) => !('disabled' in item.attributes())); - - const mockPodsLoading = () => { - state.pods.options = []; - state.pods.current = null; - }; - - const mockPodsLoaded = () => { - state.pods.options = mockPods; - state.pods.current = mockPodName; - }; - - const initWrapper = (propsData = {}) => { - wrapper = shallowMount(LogSimpleFilters, { - propsData: { - ...propsData, - }, - store, - }); - }; - - beforeEach(() => { - store = createStore(); - state = store.state.environmentLogs; - - jest.spyOn(store, 'dispatch').mockResolvedValue(); - - dispatch = store.dispatch; - }); - - afterEach(() => { - store.dispatch.mockReset(); - - if (wrapper) { - wrapper.destroy(); - } - }); - - it('displays UI elements', () => { - initWrapper(); - - expect(findPodsDropdown().exists()).toBe(true); - }); - - describe('disabled state', () => { - beforeEach(() => { - mockPodsLoading(); - initWrapper({ - disabled: true, - }); - }); - - it('displays a disabled pods dropdown', () => { - expect(findPodsDropdown().props('text')).toBe('No pod selected'); - expect(findPodsDropdown().attributes('disabled')).toBeTruthy(); - }); - }); - - describe('loading state', () => { - beforeEach(() => { - mockPodsLoading(); - initWrapper(); - }); - - it('displays an enabled pods dropdown', () => { - expect(findPodsDropdown().attributes('disabled')).toBeFalsy(); - expect(findPodsDropdown().props('text')).toBe('No pod selected'); - }); - - it('displays an empty pods dropdown', () => { - expect(findPodsNoPodsText().exists()).toBe(true); - expect(findPodsDropdownItems()).toHaveLength(0); - }); - }); - - describe('pods available state', () => { - beforeEach(() => { - mockPodsLoaded(); - initWrapper(); - }); - - it('displays an enabled pods dropdown', () => { - expect(findPodsDropdown().attributes('disabled')).toBeFalsy(); - expect(findPodsDropdown().props('text')).toBe(mockPods[0]); - }); - - it('displays a pods dropdown with items', () => { - expect(findPodsNoPodsText().exists()).toBe(false); - expect(findPodsDropdownItems()).toHaveLength(mockPods.length); - }); - - it('dropdown has one pod selected', () => { - const items = findPodsDropdownItems(); - mockPods.forEach((pod, i) => { - const item = items.at(i); - if (item.text() !== mockPodName) { - expect(item.find(GlDropdownItem).attributes('ischecked')).toBeFalsy(); - } else { - expect(item.find(GlDropdownItem).attributes('ischecked')).toBeTruthy(); - } - }); - }); - - it('when the user clicks on a pod, showPodLogs is dispatched', () => { - const items = findPodsDropdownItems(); - const index = 2; // any pod - - expect(dispatch).not.toHaveBeenCalledWith(`${module}/showPodLogs`, expect.anything()); - - items.at(index).vm.$emit('click'); - - expect(dispatch).toHaveBeenCalledWith(`${module}/showPodLogs`, mockPods[index]); - }); - }); -}); diff --git a/spec/frontend/logs/components/tokens/token_with_loading_state_spec.js b/spec/frontend/logs/components/tokens/token_with_loading_state_spec.js deleted file mode 100644 index f667a590a36..00000000000 --- a/spec/frontend/logs/components/tokens/token_with_loading_state_spec.js +++ /dev/null @@ -1,71 +0,0 @@ -import { GlFilteredSearchToken, GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; - -import TokenWithLoadingState from '~/logs/components/tokens/token_with_loading_state.vue'; - -describe('TokenWithLoadingState', () => { - let wrapper; - - const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken); - const findLoadingIcon = () => wrapper.find(GlLoadingIcon); - - const initWrapper = (props = {}, options) => { - wrapper = shallowMount(TokenWithLoadingState, { - propsData: { - cursorPosition: 'start', - ...props, - }, - ...options, - }); - }; - - beforeEach(() => {}); - - it('passes entire config correctly', () => { - const config = { - icon: 'pod', - type: 'pod', - title: 'Pod name', - unique: true, - }; - - initWrapper({ config }); - - expect(findFilteredSearchToken().props('config')).toEqual(config); - }); - - describe('suggestions are replaced', () => { - let mockNoOptsText; - let config; - let stubs; - - beforeEach(() => { - mockNoOptsText = 'No suggestions available'; - config = { - loading: false, - noOptionsText: mockNoOptsText, - }; - stubs = { - GlFilteredSearchToken: { - template: `<div><slot name="suggestions"></slot></div>`, - }, - }; - }); - - it('renders a loading icon', () => { - config.loading = true; - - initWrapper({ config }, { stubs }); - - expect(findLoadingIcon().exists()).toBe(true); - expect(wrapper.text()).toBe(''); - }); - - it('renders an empty results message', () => { - initWrapper({ config }, { stubs }); - - expect(findLoadingIcon().exists()).toBe(false); - expect(wrapper.text()).toBe(mockNoOptsText); - }); - }); -}); diff --git a/spec/frontend/logs/mock_data.js b/spec/frontend/logs/mock_data.js deleted file mode 100644 index 14c8f7a2ba2..00000000000 --- a/spec/frontend/logs/mock_data.js +++ /dev/null @@ -1,71 +0,0 @@ -const mockProjectPath = 'root/autodevops-deploy'; - -export const mockEnvName = 'production'; -export const mockEnvironmentsEndpoint = `${mockProjectPath}/environments.json`; -export const mockEnvId = '99'; -export const mockDocumentationPath = '/documentation.md'; -export const mockLogsEndpoint = '/dummy_logs_path.json'; -export const mockCursor = 'MOCK_CURSOR'; -export const mockNextCursor = 'MOCK_NEXT_CURSOR'; - -const makeMockEnvironment = (id, name, advancedQuerying) => ({ - id, - project_path: mockProjectPath, - name, - logs_api_path: mockLogsEndpoint, - enable_advanced_logs_querying: advancedQuerying, -}); - -export const mockEnvironment = makeMockEnvironment(mockEnvId, mockEnvName, true); -export const mockEnvironments = [ - mockEnvironment, - makeMockEnvironment(101, 'staging', false), - makeMockEnvironment(102, 'review/a-feature', false), -]; - -export const mockPodName = 'production-764c58d697-aaaaa'; -export const mockPods = [ - mockPodName, - 'production-764c58d697-bbbbb', - 'production-764c58d697-ccccc', - 'production-764c58d697-ddddd', -]; - -export const mockLogsResult = [ - { - timestamp: '2019-12-13T13:43:18.2760123Z', - message: 'log line 1', - pod: 'foo', - }, - { - timestamp: '2019-12-13T13:43:18.2760123Z', - message: 'log line A', - pod: 'bar', - }, - { - timestamp: '2019-12-13T13:43:26.8420123Z', - message: 'log line 2', - pod: 'foo', - }, - { - timestamp: '2019-12-13T13:43:26.8420123Z', - message: 'log line B', - pod: 'bar', - }, -]; - -export const mockTrace = [ - 'Dec 13 13:43:18.276 | foo | log line 1', - 'Dec 13 13:43:18.276 | bar | log line A', - 'Dec 13 13:43:26.842 | foo | log line 2', - 'Dec 13 13:43:26.842 | bar | log line B', -]; - -export const mockResponse = { - pod_name: mockPodName, - pods: mockPods, - logs: mockLogsResult, - cursor: mockNextCursor, -}; - -export const mockSearch = 'foo +bar'; diff --git a/spec/frontend/logs/stores/actions_spec.js b/spec/frontend/logs/stores/actions_spec.js deleted file mode 100644 index 46ef1500a20..00000000000 --- a/spec/frontend/logs/stores/actions_spec.js +++ /dev/null @@ -1,521 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import testAction from 'helpers/vuex_action_helper'; -import axios from '~/lib/utils/axios_utils'; -import { convertToFixedRange } from '~/lib/utils/datetime_range'; -import { TOKEN_TYPE_POD_NAME } from '~/logs/constants'; -import { - setInitData, - showFilteredLogs, - showPodLogs, - fetchEnvironments, - fetchLogs, - fetchMoreLogsPrepend, -} from '~/logs/stores/actions'; -import * as types from '~/logs/stores/mutation_types'; -import logsPageState from '~/logs/stores/state'; -import Tracking from '~/tracking'; - -import { defaultTimeRange } from '~/vue_shared/constants'; - -import { - mockPodName, - mockEnvironmentsEndpoint, - mockEnvironments, - mockPods, - mockLogsResult, - mockEnvName, - mockSearch, - mockLogsEndpoint, - mockResponse, - mockCursor, - mockNextCursor, -} from '../mock_data'; - -jest.mock('~/lib/utils/datetime_range'); -jest.mock('~/logs/utils'); - -const mockDefaultRange = { - start: '2020-01-10T18:00:00.000Z', - end: '2020-01-10T19:00:00.000Z', -}; -const mockFixedRange = { - start: '2020-01-09T18:06:20.000Z', - end: '2020-01-09T18:36:20.000Z', -}; -const mockRollingRange = { - duration: 120, -}; -const mockRollingRangeAsFixed = { - start: '2020-01-10T18:00:00.000Z', - end: '2020-01-10T17:58:00.000Z', -}; - -describe('Logs Store actions', () => { - let state; - let mock; - - const latestGetParams = () => mock.history.get[mock.history.get.length - 1].params; - - convertToFixedRange.mockImplementation((range) => { - if (range === defaultTimeRange) { - return { ...mockDefaultRange }; - } - if (range === mockFixedRange) { - return { ...mockFixedRange }; - } - if (range === mockRollingRange) { - return { ...mockRollingRangeAsFixed }; - } - throw new Error('Invalid time range'); - }); - - beforeEach(() => { - state = logsPageState(); - }); - - describe('setInitData', () => { - it('should commit environment and pod name mutation', () => - testAction( - setInitData, - { timeRange: mockFixedRange, environmentName: mockEnvName, podName: mockPodName }, - state, - [ - { type: types.SET_TIME_RANGE, payload: mockFixedRange }, - { type: types.SET_PROJECT_ENVIRONMENT, payload: mockEnvName }, - { type: types.SET_CURRENT_POD_NAME, payload: mockPodName }, - ], - )); - }); - - describe('showFilteredLogs', () => { - it('empty search should filter with defaults', () => - testAction( - showFilteredLogs, - undefined, - state, - [ - { type: types.SET_CURRENT_POD_NAME, payload: null }, - { type: types.SET_SEARCH, payload: '' }, - ], - [{ type: 'fetchLogs', payload: 'used_search_bar' }], - )); - - it('text search should filter with a search term', () => - testAction( - showFilteredLogs, - [mockSearch], - state, - [ - { type: types.SET_CURRENT_POD_NAME, payload: null }, - { type: types.SET_SEARCH, payload: mockSearch }, - ], - [{ type: 'fetchLogs', payload: 'used_search_bar' }], - )); - - it('pod search should filter with a search term', () => - testAction( - showFilteredLogs, - [{ type: TOKEN_TYPE_POD_NAME, value: { data: mockPodName, operator: '=' } }], - state, - [ - { type: types.SET_CURRENT_POD_NAME, payload: mockPodName }, - { type: types.SET_SEARCH, payload: '' }, - ], - [{ type: 'fetchLogs', payload: 'used_search_bar' }], - )); - - it('pod search should filter with a pod selection and a search term', () => - testAction( - showFilteredLogs, - [{ type: TOKEN_TYPE_POD_NAME, value: { data: mockPodName, operator: '=' } }, mockSearch], - state, - [ - { type: types.SET_CURRENT_POD_NAME, payload: mockPodName }, - { type: types.SET_SEARCH, payload: mockSearch }, - ], - [{ type: 'fetchLogs', payload: 'used_search_bar' }], - )); - - it('pod search should filter with a pod selection and two search terms', () => - testAction( - showFilteredLogs, - ['term1', 'term2'], - state, - [ - { type: types.SET_CURRENT_POD_NAME, payload: null }, - { type: types.SET_SEARCH, payload: `term1 term2` }, - ], - [{ type: 'fetchLogs', payload: 'used_search_bar' }], - )); - - it('pod search should filter with a pod selection and a search terms before and after', () => - testAction( - showFilteredLogs, - [ - 'term1', - { type: TOKEN_TYPE_POD_NAME, value: { data: mockPodName, operator: '=' } }, - 'term2', - ], - state, - [ - { type: types.SET_CURRENT_POD_NAME, payload: mockPodName }, - { type: types.SET_SEARCH, payload: `term1 term2` }, - ], - [{ type: 'fetchLogs', payload: 'used_search_bar' }], - )); - }); - - describe('showPodLogs', () => { - it('should commit pod name', () => - testAction( - showPodLogs, - mockPodName, - state, - [{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName }], - [{ type: 'fetchLogs', payload: 'pod_log_changed' }], - )); - }); - - describe('fetchEnvironments', () => { - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - it('should commit RECEIVE_ENVIRONMENTS_DATA_SUCCESS mutation on correct data', () => { - mock.onGet(mockEnvironmentsEndpoint).replyOnce(200, mockEnvironments); - return testAction( - fetchEnvironments, - mockEnvironmentsEndpoint, - state, - [ - { type: types.REQUEST_ENVIRONMENTS_DATA }, - { type: types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, payload: mockEnvironments }, - ], - [{ type: 'fetchLogs', payload: 'environment_selected' }], - ); - }); - - it('should commit RECEIVE_ENVIRONMENTS_DATA_ERROR on wrong data', () => { - mock.onGet(mockEnvironmentsEndpoint).replyOnce(500); - return testAction( - fetchEnvironments, - mockEnvironmentsEndpoint, - state, - [ - { type: types.REQUEST_ENVIRONMENTS_DATA }, - { type: types.RECEIVE_ENVIRONMENTS_DATA_ERROR }, - ], - [], - ); - }); - }); - - describe('when the backend responds succesfully', () => { - let expectedMutations; - let expectedActions; - - beforeEach(() => { - mock = new MockAdapter(axios); - mock.onGet(mockLogsEndpoint).reply(200, mockResponse); - mock.onGet(mockLogsEndpoint).replyOnce(202); // mock reactive cache - - state.environments.options = mockEnvironments; - state.environments.current = mockEnvName; - }); - - afterEach(() => { - mock.reset(); - }); - - describe('fetchLogs', () => { - beforeEach(() => { - expectedMutations = [ - { type: types.REQUEST_LOGS_DATA }, - { - type: types.RECEIVE_LOGS_DATA_SUCCESS, - payload: { logs: mockLogsResult, cursor: mockNextCursor }, - }, - { type: types.SET_CURRENT_POD_NAME, payload: mockPodName }, - { type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods }, - ]; - - expectedActions = []; - }); - - it('should commit logs and pod data when there is pod name defined', () => { - state.pods.current = mockPodName; - state.timeRange.current = mockFixedRange; - - return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => { - expect(latestGetParams()).toMatchObject({ - pod_name: mockPodName, - }); - }); - }); - - it('should commit logs and pod data when there is pod name defined and a non-default date range', () => { - state.pods.current = mockPodName; - state.timeRange.current = mockFixedRange; - state.logs.cursor = mockCursor; - - return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => { - expect(latestGetParams()).toEqual({ - pod_name: mockPodName, - start_time: mockFixedRange.start, - end_time: mockFixedRange.end, - cursor: mockCursor, - }); - }); - }); - - it('should commit logs and pod data when there is pod name and search and a faulty date range', () => { - state.pods.current = mockPodName; - state.search = mockSearch; - state.timeRange.current = 'INVALID_TIME_RANGE'; - - expectedMutations.splice(1, 0, { - type: types.SHOW_TIME_RANGE_INVALID_WARNING, - }); - - return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => { - expect(latestGetParams()).toEqual({ - pod_name: mockPodName, - search: mockSearch, - }); - }); - }); - - it('should commit logs and pod data when no pod name defined', () => { - state.timeRange.current = defaultTimeRange; - - return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => { - expect(latestGetParams()).toEqual({ - start_time: expect.any(String), - end_time: expect.any(String), - }); - }); - }); - }); - - describe('fetchMoreLogsPrepend', () => { - beforeEach(() => { - expectedMutations = [ - { type: types.REQUEST_LOGS_DATA_PREPEND }, - { - type: types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS, - payload: { logs: mockLogsResult, cursor: mockNextCursor }, - }, - ]; - - expectedActions = []; - }); - - it('should commit logs and pod data when there is pod name defined', () => { - state.pods.current = mockPodName; - state.timeRange.current = mockFixedRange; - - expectedActions = []; - - return testAction( - fetchMoreLogsPrepend, - null, - state, - expectedMutations, - expectedActions, - () => { - expect(latestGetParams()).toMatchObject({ - pod_name: mockPodName, - }); - }, - ); - }); - - it('should commit logs and pod data when there is pod name defined and a non-default date range', () => { - state.pods.current = mockPodName; - state.timeRange.current = mockFixedRange; - state.logs.cursor = mockCursor; - - return testAction( - fetchMoreLogsPrepend, - null, - state, - expectedMutations, - expectedActions, - () => { - expect(latestGetParams()).toEqual({ - pod_name: mockPodName, - start_time: mockFixedRange.start, - end_time: mockFixedRange.end, - cursor: mockCursor, - }); - }, - ); - }); - - it('should commit logs and pod data when there is pod name and search and a faulty date range', () => { - state.pods.current = mockPodName; - state.search = mockSearch; - state.timeRange.current = 'INVALID_TIME_RANGE'; - - expectedMutations.splice(1, 0, { - type: types.SHOW_TIME_RANGE_INVALID_WARNING, - }); - - return testAction( - fetchMoreLogsPrepend, - null, - state, - expectedMutations, - expectedActions, - () => { - expect(latestGetParams()).toEqual({ - pod_name: mockPodName, - search: mockSearch, - }); - }, - ); - }); - - it('should commit logs and pod data when no pod name defined', () => { - state.timeRange.current = defaultTimeRange; - - return testAction( - fetchMoreLogsPrepend, - null, - state, - expectedMutations, - expectedActions, - () => { - expect(latestGetParams()).toEqual({ - start_time: expect.any(String), - end_time: expect.any(String), - }); - }, - ); - }); - - it('should not commit logs or pod data when it has reached the end', () => { - state.logs.isComplete = true; - state.logs.cursor = null; - - return testAction( - fetchMoreLogsPrepend, - null, - state, - [], // no mutations done - [], // no actions dispatched - () => { - expect(mock.history.get).toHaveLength(0); - }, - ); - }); - }); - }); - - describe('when the backend responds with an error', () => { - beforeEach(() => { - mock = new MockAdapter(axios); - mock.onGet(mockLogsEndpoint).reply(500); - }); - - afterEach(() => { - mock.reset(); - }); - - it('fetchLogs should commit logs and pod errors', () => { - state.environments.options = mockEnvironments; - state.environments.current = mockEnvName; - state.timeRange.current = defaultTimeRange; - - return testAction( - fetchLogs, - null, - state, - [ - { type: types.REQUEST_LOGS_DATA }, - { type: types.RECEIVE_PODS_DATA_ERROR }, - { type: types.RECEIVE_LOGS_DATA_ERROR }, - ], - [], - () => { - expect(mock.history.get[0].url).toBe(mockLogsEndpoint); - }, - ); - }); - - it('fetchMoreLogsPrepend should commit logs and pod errors', () => { - state.environments.options = mockEnvironments; - state.environments.current = mockEnvName; - state.timeRange.current = defaultTimeRange; - - return testAction( - fetchMoreLogsPrepend, - null, - state, - [ - { type: types.REQUEST_LOGS_DATA_PREPEND }, - { type: types.RECEIVE_LOGS_DATA_PREPEND_ERROR }, - ], - [], - () => { - expect(mock.history.get[0].url).toBe(mockLogsEndpoint); - }, - ); - }); - }); -}); - -describe('Tracking user interaction', () => { - let commit; - let dispatch; - let state; - let mock; - - beforeEach(() => { - jest.spyOn(Tracking, 'event'); - commit = jest.fn(); - dispatch = jest.fn(); - state = logsPageState(); - state.environments.options = mockEnvironments; - state.environments.current = mockEnvName; - - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.reset(); - }); - - describe('Logs with data', () => { - beforeEach(() => { - mock.onGet(mockLogsEndpoint).reply(200, mockResponse); - mock.onGet(mockLogsEndpoint).replyOnce(202); // mock reactive cache - }); - - it('tracks fetched logs with data', () => { - return fetchLogs({ state, commit, dispatch }, 'environment_selected').then(() => { - expect(Tracking.event).toHaveBeenCalledWith(document.body.dataset.page, 'logs_view', { - label: 'environment_selected', - property: 'count', - value: 1, - }); - }); - }); - }); - - describe('Logs without data', () => { - beforeEach(() => { - mock.onGet(mockLogsEndpoint).reply(200, { - ...mockResponse, - logs: [], - }); - mock.onGet(mockLogsEndpoint).replyOnce(202); // mock reactive cache - }); - - it('does not track empty log responses', () => { - return fetchLogs({ state, commit, dispatch }).then(() => { - expect(Tracking.event).not.toHaveBeenCalled(); - }); - }); - }); -}); diff --git a/spec/frontend/logs/stores/getters_spec.js b/spec/frontend/logs/stores/getters_spec.js deleted file mode 100644 index 9d213d8c01f..00000000000 --- a/spec/frontend/logs/stores/getters_spec.js +++ /dev/null @@ -1,75 +0,0 @@ -import { trace, showAdvancedFilters } from '~/logs/stores/getters'; -import logsPageState from '~/logs/stores/state'; - -import { mockLogsResult, mockTrace, mockEnvName, mockEnvironments } from '../mock_data'; - -describe('Logs Store getters', () => { - let state; - - beforeEach(() => { - state = logsPageState(); - }); - - describe('trace', () => { - describe('when state is initialized', () => { - it('returns an empty string', () => { - expect(trace(state)).toEqual(''); - }); - }); - - describe('when state logs are empty', () => { - beforeEach(() => { - state.logs.lines = []; - }); - - it('returns an empty string', () => { - expect(trace(state)).toEqual(''); - }); - }); - - describe('when state logs are set', () => { - beforeEach(() => { - state.logs.lines = mockLogsResult; - }); - - it('returns an empty string', () => { - expect(trace(state)).toEqual(mockTrace.join('\n')); - }); - }); - }); - - describe('showAdvancedFilters', () => { - describe('when no environments are set', () => { - beforeEach(() => { - state.environments.current = mockEnvName; - state.environments.options = []; - }); - - it('returns false', () => { - expect(showAdvancedFilters(state)).toBe(false); - }); - }); - - describe('when the environment supports filters', () => { - beforeEach(() => { - state.environments.current = mockEnvName; - state.environments.options = mockEnvironments; - }); - - it('returns true', () => { - expect(showAdvancedFilters(state)).toBe(true); - }); - }); - - describe('when the environment does not support filters', () => { - beforeEach(() => { - state.environments.options = mockEnvironments; - state.environments.current = mockEnvironments[1].name; - }); - - it('returns true', () => { - expect(showAdvancedFilters(state)).toBe(false); - }); - }); - }); -}); diff --git a/spec/frontend/logs/stores/mutations_spec.js b/spec/frontend/logs/stores/mutations_spec.js deleted file mode 100644 index 988197a8350..00000000000 --- a/spec/frontend/logs/stores/mutations_spec.js +++ /dev/null @@ -1,257 +0,0 @@ -import * as types from '~/logs/stores/mutation_types'; -import mutations from '~/logs/stores/mutations'; - -import logsPageState from '~/logs/stores/state'; -import { - mockEnvName, - mockEnvironments, - mockPods, - mockPodName, - mockLogsResult, - mockSearch, - mockCursor, - mockNextCursor, -} from '../mock_data'; - -describe('Logs Store Mutations', () => { - let state; - - beforeEach(() => { - state = logsPageState(); - }); - - it('ensures mutation types are correctly named', () => { - Object.keys(types).forEach((k) => { - expect(k).toEqual(types[k]); - }); - }); - - describe('SET_PROJECT_ENVIRONMENT', () => { - it('sets the environment', () => { - mutations[types.SET_PROJECT_ENVIRONMENT](state, mockEnvName); - expect(state.environments.current).toEqual(mockEnvName); - }); - }); - - describe('SET_SEARCH', () => { - it('sets the search', () => { - mutations[types.SET_SEARCH](state, mockSearch); - expect(state.search).toEqual(mockSearch); - }); - }); - - describe('REQUEST_ENVIRONMENTS_DATA', () => { - it('inits data', () => { - mutations[types.REQUEST_ENVIRONMENTS_DATA](state); - expect(state.environments.options).toEqual([]); - expect(state.environments.isLoading).toEqual(true); - }); - }); - - describe('RECEIVE_ENVIRONMENTS_DATA_SUCCESS', () => { - it('receives environments data and stores it as options', () => { - expect(state.environments.options).toEqual([]); - - mutations[types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS](state, mockEnvironments); - - expect(state.environments.options).toEqual(mockEnvironments); - expect(state.environments.isLoading).toEqual(false); - }); - }); - - describe('RECEIVE_ENVIRONMENTS_DATA_ERROR', () => { - it('captures an error loading environments', () => { - mutations[types.RECEIVE_ENVIRONMENTS_DATA_ERROR](state); - - expect(state.environments).toEqual({ - options: [], - isLoading: false, - current: null, - fetchError: true, - }); - }); - }); - - describe('REQUEST_LOGS_DATA', () => { - it('starts loading for logs', () => { - mutations[types.REQUEST_LOGS_DATA](state); - - expect(state.timeRange.current).toEqual({ - start: expect.any(String), - end: expect.any(String), - }); - - expect(state.logs).toEqual({ - lines: [], - cursor: null, - fetchError: false, - isLoading: true, - isComplete: false, - }); - }); - }); - - describe('RECEIVE_LOGS_DATA_SUCCESS', () => { - it('receives logs lines and cursor', () => { - mutations[types.RECEIVE_LOGS_DATA_SUCCESS](state, { - logs: mockLogsResult, - cursor: mockCursor, - }); - - expect(state.logs).toEqual({ - lines: mockLogsResult, - isLoading: false, - cursor: mockCursor, - isComplete: false, - fetchError: false, - }); - }); - - it('receives logs lines and a null cursor to indicate the end', () => { - mutations[types.RECEIVE_LOGS_DATA_SUCCESS](state, { - logs: mockLogsResult, - cursor: null, - }); - - expect(state.logs).toEqual({ - lines: mockLogsResult, - isLoading: false, - cursor: null, - isComplete: true, - fetchError: false, - }); - }); - }); - - describe('RECEIVE_LOGS_DATA_ERROR', () => { - it('receives log data error and stops loading', () => { - mutations[types.RECEIVE_LOGS_DATA_ERROR](state); - - expect(state.logs).toEqual({ - lines: [], - isLoading: false, - cursor: null, - isComplete: false, - fetchError: true, - }); - }); - }); - - describe('REQUEST_LOGS_DATA_PREPEND', () => { - it('receives logs lines and cursor', () => { - mutations[types.REQUEST_LOGS_DATA_PREPEND](state); - - expect(state.logs.isLoading).toBe(true); - }); - }); - - describe('RECEIVE_LOGS_DATA_PREPEND_SUCCESS', () => { - it('receives logs lines and cursor', () => { - mutations[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, { - logs: mockLogsResult, - cursor: mockCursor, - }); - - expect(state.logs).toEqual({ - lines: mockLogsResult, - isLoading: false, - cursor: mockCursor, - isComplete: false, - fetchError: false, - }); - }); - - it('receives additional logs lines and a new cursor', () => { - mutations[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, { - logs: mockLogsResult, - cursor: mockCursor, - }); - - mutations[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, { - logs: mockLogsResult, - cursor: mockNextCursor, - }); - - expect(state.logs).toEqual({ - lines: [...mockLogsResult, ...mockLogsResult], - isLoading: false, - cursor: mockNextCursor, - isComplete: false, - fetchError: false, - }); - }); - - it('receives logs lines and a null cursor to indicate is complete', () => { - mutations[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, { - logs: mockLogsResult, - cursor: null, - }); - - expect(state.logs).toEqual({ - lines: mockLogsResult, - isLoading: false, - cursor: null, - isComplete: true, - fetchError: false, - }); - }); - }); - - describe('RECEIVE_LOGS_DATA_PREPEND_ERROR', () => { - it('receives logs lines and cursor', () => { - mutations[types.RECEIVE_LOGS_DATA_PREPEND_ERROR](state); - - expect(state.logs.isLoading).toBe(false); - expect(state.logs.fetchError).toBe(true); - }); - }); - - describe('SET_CURRENT_POD_NAME', () => { - it('set current pod name', () => { - mutations[types.SET_CURRENT_POD_NAME](state, mockPodName); - - expect(state.pods.current).toEqual(mockPodName); - }); - }); - - describe('SET_TIME_RANGE', () => { - it('sets a default range', () => { - expect(state.timeRange.selected).toEqual(expect.any(Object)); - expect(state.timeRange.current).toEqual(expect.any(Object)); - }); - - it('sets a time range', () => { - const mockRange = { - start: '2020-01-10T18:00:00.000Z', - end: '2020-01-10T10:00:00.000Z', - }; - mutations[types.SET_TIME_RANGE](state, mockRange); - - expect(state.timeRange.selected).toEqual(mockRange); - expect(state.timeRange.current).toEqual(mockRange); - }); - }); - - describe('RECEIVE_PODS_DATA_SUCCESS', () => { - it('receives pods data success', () => { - mutations[types.RECEIVE_PODS_DATA_SUCCESS](state, mockPods); - - expect(state.pods).toEqual( - expect.objectContaining({ - options: mockPods, - }), - ); - }); - }); - describe('RECEIVE_PODS_DATA_ERROR', () => { - it('receives pods data error', () => { - mutations[types.RECEIVE_PODS_DATA_ERROR](state); - - expect(state.pods).toEqual( - expect.objectContaining({ - options: [], - }), - ); - }); - }); -}); diff --git a/spec/frontend/merge_request_tabs_spec.js b/spec/frontend/merge_request_tabs_spec.js index f0f051cbc8b..2001bb5f95e 100644 --- a/spec/frontend/merge_request_tabs_spec.js +++ b/spec/frontend/merge_request_tabs_spec.js @@ -2,6 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import initMrPage from 'helpers/init_vue_mr_page_helper'; +import { stubPerformanceWebAPI } from 'helpers/performance'; import axios from '~/lib/utils/axios_utils'; import MergeRequestTabs from '~/merge_request_tabs'; import '~/lib/utils/common_utils'; @@ -24,6 +25,8 @@ describe('MergeRequestTabs', () => { }; beforeEach(() => { + stubPerformanceWebAPI(); + initMrPage(); testContext.class = new MergeRequestTabs({ stubLocation }); @@ -331,6 +334,8 @@ describe('MergeRequestTabs', () => { ${'diffs'} | ${true} | ${'hides'} ${'commits'} | ${true} | ${'hides'} `('it $hidesText expand button on $tab tab', ({ tab, hides }) => { + window.gon = { features: { movedMrSidebar: true } }; + const expandButton = document.createElement('div'); expandButton.classList.add('js-expand-sidebar'); @@ -344,16 +349,16 @@ describe('MergeRequestTabs', () => { testContext.class = new MergeRequestTabs({ stubLocation }); testContext.class.tabShown(tab, 'foobar'); - expect(testContext.class.expandSidebar.classList.contains('gl-display-none!')).toBe(hides); + testContext.class.expandSidebar.forEach((el) => { + expect(el.classList.contains('gl-display-none!')).toBe(hides); + }); + + window.gon = {}; }); describe('when switching tabs', () => { const SCROLL_TOP = 100; - beforeAll(() => { - jest.useFakeTimers(); - }); - beforeEach(() => { jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); testContext.class.mergeRequestTabs = document.createElement('div'); @@ -362,10 +367,6 @@ describe('MergeRequestTabs', () => { testContext.class.scrollPositions = { newTab: SCROLL_TOP }; }); - afterAll(() => { - jest.useRealTimers(); - }); - it('scrolls to the stored position, if one is stored', () => { testContext.class.tabShown('newTab'); diff --git a/spec/frontend/milestones/components/delete_milestone_modal_spec.js b/spec/frontend/milestones/components/delete_milestone_modal_spec.js index b9ba0833c4f..6692a3b9347 100644 --- a/spec/frontend/milestones/components/delete_milestone_modal_spec.js +++ b/spec/frontend/milestones/components/delete_milestone_modal_spec.js @@ -1,44 +1,59 @@ -import Vue from 'vue'; +import { GlSprintf, GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; -import mountComponent from 'helpers/vue_mount_component_helper'; import axios from '~/lib/utils/axios_utils'; -import { redirectTo } from '~/lib/utils/url_utility'; -import deleteMilestoneModal from '~/milestones/components/delete_milestone_modal.vue'; +import DeleteMilestoneModal from '~/milestones/components/delete_milestone_modal.vue'; import eventHub from '~/milestones/event_hub'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { createAlert } from '~/flash'; -jest.mock('~/lib/utils/url_utility', () => ({ - ...jest.requireActual('~/lib/utils/url_utility'), - redirectTo: jest.fn(), -})); +jest.mock('~/lib/utils/url_utility'); +jest.mock('~/flash'); -describe('delete_milestone_modal.vue', () => { - const Component = Vue.extend(deleteMilestoneModal); - const props = { +describe('Delete milestone modal', () => { + let wrapper; + const mockProps = { issueCount: 1, mergeRequestCount: 2, milestoneId: 3, milestoneTitle: 'my milestone title', milestoneUrl: `${TEST_HOST}/delete_milestone_modal.vue/milestone`, }; - let vm; + + const findModal = () => wrapper.findComponent(GlModal); + + const createComponent = (props) => { + wrapper = shallowMount(DeleteMilestoneModal, { + propsData: { + ...mockProps, + ...props, + }, + stubs: { + GlSprintf, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); describe('onSubmit', () => { beforeEach(() => { - vm = mountComponent(Component, props); jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); }); it('deletes milestone and redirects to overview page', async () => { const responseURL = `${TEST_HOST}/delete_milestone_modal.vue/milestoneOverview`; jest.spyOn(axios, 'delete').mockImplementation((url) => { - expect(url).toBe(props.milestoneUrl); + expect(url).toBe(mockProps.milestoneUrl); expect(eventHub.$emit).toHaveBeenCalledWith( 'deleteMilestoneModal.requestStarted', - props.milestoneUrl, + mockProps.milestoneUrl, ); eventHub.$emit.mockReset(); return Promise.resolve({ @@ -47,55 +62,71 @@ describe('delete_milestone_modal.vue', () => { }, }); }); - - await vm.onSubmit(); + await findModal().vm.$emit('primary'); expect(redirectTo).toHaveBeenCalledWith(responseURL); expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', { - milestoneUrl: props.milestoneUrl, + milestoneUrl: mockProps.milestoneUrl, successful: true, }); }); - it('displays error if deleting milestone failed', async () => { - const dummyError = new Error('deleting milestone failed'); - dummyError.response = { status: 418 }; - jest.spyOn(axios, 'delete').mockImplementation((url) => { - expect(url).toBe(props.milestoneUrl); - expect(eventHub.$emit).toHaveBeenCalledWith( - 'deleteMilestoneModal.requestStarted', - props.milestoneUrl, - ); - eventHub.$emit.mockReset(); - return Promise.reject(dummyError); - }); + it.each` + statusCode | alertMessage + ${418} | ${`Failed to delete milestone ${mockProps.milestoneTitle}`} + ${404} | ${`Milestone ${mockProps.milestoneTitle} was not found`} + `( + 'displays error if deleting milestone failed with code $statusCode', + async ({ statusCode, alertMessage }) => { + const dummyError = new Error('deleting milestone failed'); + dummyError.response = { status: statusCode }; + jest.spyOn(axios, 'delete').mockImplementation((url) => { + expect(url).toBe(mockProps.milestoneUrl); + expect(eventHub.$emit).toHaveBeenCalledWith( + 'deleteMilestoneModal.requestStarted', + mockProps.milestoneUrl, + ); + eventHub.$emit.mockReset(); + return Promise.reject(dummyError); + }); - await expect(vm.onSubmit()).rejects.toEqual(dummyError); - expect(redirectTo).not.toHaveBeenCalled(); - expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', { - milestoneUrl: props.milestoneUrl, - successful: false, - }); - }); + await expect(wrapper.vm.onSubmit()).rejects.toEqual(dummyError); + expect(createAlert).toHaveBeenCalledWith({ + message: alertMessage, + }); + expect(redirectTo).not.toHaveBeenCalled(); + expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', { + milestoneUrl: mockProps.milestoneUrl, + successful: false, + }); + }, + ); }); - describe('text', () => { - it('contains the issue and milestone count', () => { - vm = mountComponent(Component, props); - const value = vm.text; + describe('Modal title and description', () => { + const emptyDescription = `You’re about to permanently delete the milestone ${mockProps.milestoneTitle}. This milestone is not currently used in any issues or merge requests.`; + const description = `You’re about to permanently delete the milestone ${mockProps.milestoneTitle} and remove it from 1 issue and 2 merge requests. Once deleted, it cannot be undone or recovered.`; + const title = `Delete milestone ${mockProps.milestoneTitle}?`; - expect(value).toContain('remove it from 1 issue and 2 merge requests'); + it('renders proper title', () => { + const value = findModal().props('title'); + expect(value).toBe(title); }); - it('contains neither issue nor milestone count', () => { - vm = mountComponent(Component, { - ...props, - issueCount: 0, - mergeRequestCount: 0, - }); - - const value = vm.text; + it.each` + statement | descriptionText | issueCount | mergeRequestCount + ${'1 issue and 2 merge requests'} | ${description} | ${1} | ${2} + ${'no issues and merge requests'} | ${emptyDescription} | ${0} | ${0} + `( + 'renders proper description when the milestone contains $statement', + ({ issueCount, mergeRequestCount, descriptionText }) => { + createComponent({ + issueCount, + mergeRequestCount, + }); - expect(value).toContain('is not currently used'); - }); + const value = findModal().text(); + expect(value).toBe(descriptionText); + }, + ); }); }); diff --git a/spec/frontend/milestones/components/milestone_combobox_spec.js b/spec/frontend/milestones/components/milestone_combobox_spec.js index afd85fb78ce..a8e3d13dca0 100644 --- a/spec/frontend/milestones/components/milestone_combobox_spec.js +++ b/spec/frontend/milestones/components/milestone_combobox_spec.js @@ -154,9 +154,9 @@ describe('Milestone combobox component', () => { }; describe('initialization behavior', () => { - beforeEach(createComponent); - it('initializes the dropdown with milestones when mounted', () => { + createComponent(); + return waitForRequests().then(() => { expect(projectMilestonesApiCallSpy).toHaveBeenCalledTimes(1); expect(groupMilestonesApiCallSpy).toHaveBeenCalledTimes(1); @@ -164,6 +164,8 @@ describe('Milestone combobox component', () => { }); it('shows a spinner while network requests are in progress', () => { + createComponent(); + expect(findLoadingIcon().exists()).toBe(true); return waitForRequests().then(() => { @@ -172,6 +174,8 @@ describe('Milestone combobox component', () => { }); it('shows additional links', () => { + createComponent(); + const links = wrapper.findAll('[data-testid="milestone-combobox-extra-links"]'); links.wrappers.forEach((item, idx) => { expect(item.text()).toBe(extraLinks[idx].text); 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 a9f37f90561..14f04d9b767 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -35,7 +35,7 @@ exports[`Dashboard template matches the default snapshot 1`] = ` class="prometheus-graphs-header d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 border-bottom bg-gray-light" > <div - class="mb-2 mr-2 d-flex d-sm-block" + class="gl-mb-3 gl-mr-3 gl-display-flex gl-sm-display-block" > <dashboards-dropdown-stub class="flex-grow-1" diff --git a/spec/frontend/monitoring/components/dashboard_panel_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js index 1f9eb03b5d4..7c54a4742ac 100644 --- a/spec/frontend/monitoring/components/dashboard_panel_spec.js +++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js @@ -5,7 +5,6 @@ import Vuex from 'vuex'; import { nextTick } from 'vue'; import { setTestTimeout } from 'helpers/timeout'; import axios from '~/lib/utils/axios_utils'; -import invalidUrl from '~/lib/utils/invalid_url'; import MonitorAnomalyChart from '~/monitoring/components/charts/anomaly.vue'; import MonitorBarChart from '~/monitoring/components/charts/bar.vue'; @@ -27,13 +26,7 @@ import { heatmapGraphData, barGraphData, } from '../graph_data'; -import { - mockLogsHref, - mockLogsPath, - mockNamespace, - mockNamespacedData, - mockTimeRange, -} from '../mock_data'; +import { mockNamespace, mockNamespacedData, mockTimeRange } from '../mock_data'; const mocks = { $toast: { @@ -65,7 +58,6 @@ describe('Dashboard Panel', () => { }, store, mocks, - provide: { glFeatures: { monitorLogging: true } }, ...options, }); }; @@ -335,86 +327,6 @@ describe('Dashboard Panel', () => { }); }); - describe('View Logs dropdown item', () => { - const findViewLogsLink = () => wrapper.find({ ref: 'viewLogsLink' }); - - beforeEach(async () => { - createWrapper(); - await nextTick(); - }); - - it('is not present by default', async () => { - await nextTick(); - expect(findViewLogsLink().exists()).toBe(false); - }); - - it('is not present if a time range is not set', async () => { - state.logsPath = mockLogsPath; - state.timeRange = null; - - await nextTick(); - expect(findViewLogsLink().exists()).toBe(false); - }); - - it('is not present if the logs path is default', async () => { - state.logsPath = invalidUrl; - state.timeRange = mockTimeRange; - - await nextTick(); - expect(findViewLogsLink().exists()).toBe(false); - }); - - it('is not present if the logs path is not set', async () => { - state.logsPath = null; - state.timeRange = mockTimeRange; - - await nextTick(); - expect(findViewLogsLink().exists()).toBe(false); - }); - - it('is present when logs path and time a range is present', async () => { - state.logsPath = mockLogsPath; - state.timeRange = mockTimeRange; - - await nextTick(); - expect(findViewLogsLink().attributes('href')).toMatch(mockLogsHref); - }); - - describe(':monitor_logging feature flag', () => { - it.each` - flagState | logsState | expected - ${true} | ${'shows'} | ${true} - ${false} | ${'hides'} | ${false} - `('$logsState logs when flag state is $flagState', async ({ flagState, expected }) => { - createWrapper({}, { provide: { glFeatures: { monitorLogging: flagState } } }); - state.logsPath = mockLogsPath; - state.timeRange = mockTimeRange; - await nextTick(); - - expect(findViewLogsLink().exists()).toBe(expected); - }); - }); - - it('it is overridden when a datazoom event is received', async () => { - state.logsPath = mockLogsPath; - state.timeRange = mockTimeRange; - - const zoomedTimeRange = { - start: '2020-01-01T00:00:00.000Z', - end: '2020-01-01T01:00:00.000Z', - }; - - findTimeChart().vm.$emit('datazoom', zoomedTimeRange); - - await nextTick(); - const start = encodeURIComponent(zoomedTimeRange.start); - const end = encodeURIComponent(zoomedTimeRange.end); - expect(findViewLogsLink().attributes('href')).toMatch( - `${mockLogsPath}?start=${start}&end=${end}`, - ); - }); - }); - describe('when clipboard data is available', () => { const clipboardText = 'A value to copy.'; @@ -507,14 +419,6 @@ describe('Dashboard Panel', () => { createWrapper({ namespace: mockNamespace }); }); - it('handles namespaced time range and logs path state', async () => { - store.state[mockNamespace].timeRange = mockTimeRange; - store.state[mockNamespace].logsPath = mockLogsPath; - - await nextTick(); - expect(wrapper.find({ ref: 'viewLogsLink' }).attributes().href).toBe(mockLogsHref); - }); - it('handles namespaced deployment data state', async () => { store.state[mockNamespace].deploymentData = mockDeploymentData; diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js index 6c5972e1140..90171cfc65e 100644 --- a/spec/frontend/monitoring/components/dashboard_spec.js +++ b/spec/frontend/monitoring/components/dashboard_spec.js @@ -75,6 +75,7 @@ describe('Dashboard', () => { if (store.dispatch.mockReset) { store.dispatch.mockReset(); } + wrapper.destroy(); }); describe('request information to the server', () => { @@ -569,28 +570,37 @@ describe('Dashboard', () => { const findDraggablePanels = () => wrapper.findAll('.js-draggable-panel'); const findRearrangeButton = () => wrapper.find('.js-rearrange-button'); - beforeEach(async () => { + const setup = async () => { // call original dispatch store.dispatch.mockRestore(); createShallowWrapper({ hasMetrics: true }); setupStoreWithData(store); await nextTick(); - }); + }; + + it('wraps vuedraggable', async () => { + await setup(); - it('wraps vuedraggable', () => { expect(findDraggablePanels().exists()).toBe(true); expect(findDraggablePanels().length).toEqual(metricsDashboardPanelCount); }); - it('is disabled by default', () => { + it('is disabled by default', async () => { + await setup(); + expect(findRearrangeButton().exists()).toBe(false); expect(findEnabledDraggables().length).toBe(0); }); describe('when rearrange is enabled', () => { beforeEach(async () => { - wrapper.setProps({ rearrangePanelsAvailable: true }); + // call original dispatch + store.dispatch.mockRestore(); + + createShallowWrapper({ hasMetrics: true, rearrangePanelsAvailable: true }); + setupStoreWithData(store); + await nextTick(); }); @@ -602,17 +612,18 @@ describe('Dashboard', () => { const findFirstDraggableRemoveButton = () => findDraggablePanels().at(0).find('.js-draggable-remove'); - beforeEach(async () => { + it('it enables draggables', async () => { findRearrangeButton().vm.$emit('click'); await nextTick(); - }); - it('it enables draggables', () => { expect(findRearrangeButton().attributes('pressed')).toBeTruthy(); expect(findEnabledDraggables().wrappers).toEqual(findDraggables().wrappers); }); it('metrics can be swapped', async () => { + findRearrangeButton().vm.$emit('click'); + await nextTick(); + const firstDraggable = findDraggables().at(0); const mockMetrics = [...metricsDashboardViewModel.panelGroups[0].panels]; @@ -624,6 +635,7 @@ describe('Dashboard', () => { firstDraggable.vm.$emit('input', mockMetrics); await nextTick(); + const { panels } = wrapper.vm.dashboard.panelGroups[0]; expect(panels[1].title).toEqual(firstTitle); @@ -631,18 +643,23 @@ describe('Dashboard', () => { }); it('shows a remove button, which removes a panel', async () => { + findRearrangeButton().vm.$emit('click'); + await nextTick(); + expect(findFirstDraggableRemoveButton().find('a').exists()).toBe(true); expect(findDraggablePanels().length).toEqual(metricsDashboardPanelCount); - findFirstDraggableRemoveButton().trigger('click'); + await findFirstDraggableRemoveButton().trigger('click'); - await nextTick(); expect(findDraggablePanels().length).toEqual(metricsDashboardPanelCount - 1); }); it('it disables draggables when clicked again', async () => { findRearrangeButton().vm.$emit('click'); await nextTick(); + + findRearrangeButton().vm.$emit('click'); + await nextTick(); expect(findRearrangeButton().attributes('pressed')).toBeFalsy(); expect(findEnabledDraggables().length).toBe(0); }); diff --git a/spec/frontend/monitoring/store/mutations_spec.js b/spec/frontend/monitoring/store/mutations_spec.js index ae1a4e16b30..49e8ab9ebd4 100644 --- a/spec/frontend/monitoring/store/mutations_spec.js +++ b/spec/frontend/monitoring/store/mutations_spec.js @@ -180,11 +180,6 @@ describe('Monitoring mutations', () => { }); it('should not remove previously set properties', () => { - const defaultLogsPath = stateCopy.logsPath; - - mutations[types.SET_INITIAL_STATE](stateCopy, { - logsPath: defaultLogsPath, - }); mutations[types.SET_INITIAL_STATE](stateCopy, { dashboardEndpoint: 'dashboard.json', }); @@ -196,7 +191,6 @@ describe('Monitoring mutations', () => { }); expect(stateCopy).toMatchObject({ - logsPath: defaultLogsPath, dashboardEndpoint: 'dashboard.json', projectPath: '/gitlab-org/gitlab-foss', currentEnvironmentName: 'canary', @@ -227,11 +221,6 @@ describe('Monitoring mutations', () => { }); it('should not remove previously set properties', () => { - const defaultLogsPath = stateCopy.logsPath; - - mutations[types.SET_ENDPOINTS](stateCopy, { - logsPath: defaultLogsPath, - }); mutations[types.SET_ENDPOINTS](stateCopy, { dashboardEndpoint: 'dashboard.json', }); @@ -240,7 +229,6 @@ describe('Monitoring mutations', () => { }); expect(stateCopy).toMatchObject({ - logsPath: defaultLogsPath, dashboardEndpoint: 'dashboard.json', projectPath: '/gitlab-org/gitlab-foss', }); diff --git a/spec/frontend/new_branch_spec.js b/spec/frontend/new_branch_spec.js index e4f4b3fa5b5..5a09598059d 100644 --- a/spec/frontend/new_branch_spec.js +++ b/spec/frontend/new_branch_spec.js @@ -1,4 +1,3 @@ -import $ from 'jquery'; import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import NewBranchForm from '~/new_branch_form'; @@ -11,17 +10,19 @@ describe('Branch', () => { describe('create a new branch', () => { function fillNameWith(value) { - $('.js-branch-name').val(value).trigger('blur'); + document.querySelector('.js-branch-name').value = value; + const event = new CustomEvent('blur'); + document.querySelector('.js-branch-name').dispatchEvent(event); } function expectToHaveError(error) { - expect($('.js-branch-name-error span').text()).toEqual(error); + expect(document.querySelector('.js-branch-name-error').textContent).toEqual(error); } beforeEach(() => { loadHTMLFixture('branches/new_branch.html'); - $('form').on('submit', (e) => e.preventDefault()); - testContext.form = new NewBranchForm($('.js-create-branch-form'), []); + document.querySelector('form').addEventListener('submit', (e) => e.preventDefault()); + testContext.form = new NewBranchForm(document.querySelector('.js-create-branch-form'), []); }); afterEach(() => { @@ -171,34 +172,34 @@ describe('Branch', () => { it('removes the error message when is a valid name', () => { fillNameWith('foo?bar'); - expect($('.js-branch-name-error span').length).toEqual(1); + expect(document.querySelector('.js-branch-name-error').textContent).not.toEqual(''); fillNameWith('foobar'); - expect($('.js-branch-name-error span').length).toEqual(0); + expect(document.querySelector('.js-branch-name-error').textContent).toEqual(''); }); it('can have dashes anywhere', () => { fillNameWith('-foo-bar-zoo-'); - expect($('.js-branch-name-error span').length).toEqual(0); + expect(document.querySelector('.js-branch-name-error').textContent).toEqual(''); }); it('can have underscores anywhere', () => { fillNameWith('_foo_bar_zoo_'); - expect($('.js-branch-name-error span').length).toEqual(0); + expect(document.querySelector('.js-branch-name-error').textContent).toEqual(''); }); it('can have numbers anywhere', () => { fillNameWith('1foo2bar3zoo4'); - expect($('.js-branch-name-error span').length).toEqual(0); + expect(document.querySelector('.js-branch-name-error').textContent).toEqual(''); }); it('can be only letters', () => { fillNameWith('foo'); - expect($('.js-branch-name-error span').length).toEqual(0); + expect(document.querySelector('.js-branch-name-error').textContent).toEqual(''); }); }); }); diff --git a/spec/frontend/notebook/cells/code_spec.js b/spec/frontend/notebook/cells/code_spec.js index 9a2db061278..10762a1c3a2 100644 --- a/spec/frontend/notebook/cells/code_spec.js +++ b/spec/frontend/notebook/cells/code_spec.js @@ -1,89 +1,73 @@ -import Vue, { nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; import fixture from 'test_fixtures/blob/notebook/basic.json'; -import CodeComponent from '~/notebook/cells/code.vue'; - -const Component = Vue.extend(CodeComponent); +import Code from '~/notebook/cells/code.vue'; describe('Code component', () => { - let vm; - + let wrapper; let json; + const mountComponent = (cell) => mount(Code, { propsData: { cell } }); + beforeEach(() => { // Clone fixture as it could be modified by tests json = JSON.parse(JSON.stringify(fixture)); }); - const setupComponent = (cell) => { - const comp = new Component({ - propsData: { - cell, - }, - }); - comp.$mount(); - return comp; - }; + afterEach(() => { + wrapper.destroy(); + }); describe('without output', () => { beforeEach(() => { - vm = setupComponent(json.cells[0]); - - return nextTick(); + wrapper = mountComponent(json.cells[0]); }); it('does not render output prompt', () => { - expect(vm.$el.querySelectorAll('.prompt').length).toBe(1); + expect(wrapper.findAll('.prompt')).toHaveLength(1); }); }); describe('with output', () => { beforeEach(() => { - vm = setupComponent(json.cells[2]); - - return nextTick(); + wrapper = mountComponent(json.cells[2]); }); it('does not render output prompt', () => { - expect(vm.$el.querySelectorAll('.prompt').length).toBe(2); + expect(wrapper.findAll('.prompt')).toHaveLength(2); }); it('renders output cell', () => { - expect(vm.$el.querySelector('.output')).toBeDefined(); + expect(wrapper.find('.output').exists()).toBe(true); }); }); describe('with string for output', () => { // NBFormat Version 4.1 allows outputs.text to be a string - beforeEach(async () => { + beforeEach(() => { const cell = json.cells[2]; cell.outputs[0].text = cell.outputs[0].text.join(''); - vm = setupComponent(cell); - await nextTick(); + wrapper = mountComponent(cell); }); it('does not render output prompt', () => { - expect(vm.$el.querySelectorAll('.prompt').length).toBe(2); + expect(wrapper.findAll('.prompt')).toHaveLength(2); }); it('renders output cell', () => { - expect(vm.$el.querySelector('.output')).toBeDefined(); + expect(wrapper.find('.output').exists()).toBe(true); }); }); describe('with string for cell.source', () => { - beforeEach(async () => { + beforeEach(() => { const cell = json.cells[0]; cell.source = cell.source.join(''); - - vm = setupComponent(cell); - await nextTick(); + wrapper = mountComponent(cell); }); it('renders the same input as when cell.source is an array', () => { - const expected = "console.log('test')"; - - expect(vm.$el.querySelector('.input').innerText).toContain(expected); + expect(wrapper.find('.input').text()).toContain("console.log('test')"); }); }); }); diff --git a/spec/frontend/notebook/cells/markdown_spec.js b/spec/frontend/notebook/cells/markdown_spec.js index de415b5bfe0..c757b55faf4 100644 --- a/spec/frontend/notebook/cells/markdown_spec.js +++ b/spec/frontend/notebook/cells/markdown_spec.js @@ -130,7 +130,7 @@ describe('Markdown component', () => { expect(columns[0].innerHTML).toContain('<img src="data:image/jpeg;base64'); expect(columns[1].innerHTML).toContain('<img src="data:image/png;base64'); expect(columns[2].innerHTML).toContain('<img src="data:image/jpeg;base64'); - expect(columns[3].innerHTML).toContain('<img>'); + expect(columns[3].innerHTML).toContain('<img src="attachment:bogus">'); expect(columns[4].innerHTML).toContain('<img src="https://www.google.com/'); }); }); diff --git a/spec/frontend/notebook/cells/output/index_spec.js b/spec/frontend/notebook/cells/output/index_spec.js index 8e04e4c146c..4d1d03e5e34 100644 --- a/spec/frontend/notebook/cells/output/index_spec.js +++ b/spec/frontend/notebook/cells/output/index_spec.js @@ -1,36 +1,35 @@ -import Vue, { nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; import json from 'test_fixtures/blob/notebook/basic.json'; -import CodeComponent from '~/notebook/cells/output/index.vue'; - -const Component = Vue.extend(CodeComponent); +import Output from '~/notebook/cells/output/index.vue'; describe('Output component', () => { - let vm; + let wrapper; const createComponent = (output) => { - vm = new Component({ + wrapper = mount(Output, { propsData: { outputs: [].concat(output), count: 1, }, }); - vm.$mount(); }; + afterEach(() => { + wrapper.destroy(); + }); + describe('text output', () => { beforeEach(() => { const textType = json.cells[2]; createComponent(textType.outputs[0]); - - return nextTick(); }); it('renders as plain text', () => { - expect(vm.$el.querySelector('pre')).not.toBeNull(); + expect(wrapper.find('pre').exists()).toBe(true); }); it('renders prompt', () => { - expect(vm.$el.querySelector('.prompt span')).not.toBeNull(); + expect(wrapper.find('.prompt span').exists()).toBe(true); }); }); @@ -38,12 +37,10 @@ describe('Output component', () => { beforeEach(() => { const imageType = json.cells[3]; createComponent(imageType.outputs[0]); - - return nextTick(); }); it('renders as an image', () => { - expect(vm.$el.querySelector('img')).not.toBeNull(); + expect(wrapper.find('img').exists()).toBe(true); }); }); @@ -52,16 +49,15 @@ describe('Output component', () => { const htmlType = json.cells[4]; createComponent(htmlType.outputs[0]); - expect(vm.$el.querySelector('p')).not.toBeNull(); - expect(vm.$el.querySelectorAll('p')).toHaveLength(1); - expect(vm.$el.textContent.trim()).toContain('test'); + expect(wrapper.findAll('p')).toHaveLength(1); + expect(wrapper.text()).toContain('test'); }); it('renders multiple raw HTML outputs', () => { const htmlType = json.cells[4]; createComponent([htmlType.outputs[0], htmlType.outputs[0]]); - expect(vm.$el.querySelectorAll('p')).toHaveLength(2); + expect(wrapper.findAll('p')).toHaveLength(2); }); }); @@ -77,7 +73,7 @@ describe('Output component', () => { }; createComponent(output); - expect(vm.$el.querySelector('.MathJax')).not.toBeNull(); + expect(wrapper.find('.MathJax').exists()).toBe(true); }); }); @@ -85,12 +81,10 @@ describe('Output component', () => { beforeEach(() => { const svgType = json.cells[5]; createComponent(svgType.outputs[0]); - - return nextTick(); }); it('renders as an svg', () => { - expect(vm.$el.querySelector('svg')).not.toBeNull(); + expect(wrapper.find('svg').exists()).toBe(true); }); }); @@ -98,27 +92,23 @@ describe('Output component', () => { beforeEach(() => { const unknownType = json.cells[6]; createComponent(unknownType.outputs[0]); - - return nextTick(); }); it('renders as plain text', () => { - expect(vm.$el.querySelector('pre')).not.toBeNull(); - expect(vm.$el.textContent.trim()).toContain('testing'); + expect(wrapper.find('pre').exists()).toBe(true); + expect(wrapper.text()).toContain('testing'); }); - it('renders promot', () => { - expect(vm.$el.querySelector('.prompt span')).not.toBeNull(); + it('renders prompt', () => { + expect(wrapper.find('.prompt span').exists()).toBe(true); }); - it("renders as plain text when doesn't recognise other types", async () => { + it("renders as plain text when doesn't recognise other types", () => { const unknownType = json.cells[7]; createComponent(unknownType.outputs[0]); - await nextTick(); - - expect(vm.$el.querySelector('pre')).not.toBeNull(); - expect(vm.$el.textContent.trim()).toContain('testing'); + expect(wrapper.find('pre').exists()).toBe(true); + expect(wrapper.text()).toContain('testing'); }); }); }); diff --git a/spec/frontend/notebook/cells/prompt_spec.js b/spec/frontend/notebook/cells/prompt_spec.js index 89b2d7b2b90..0cda0c5bc2b 100644 --- a/spec/frontend/notebook/cells/prompt_spec.js +++ b/spec/frontend/notebook/cells/prompt_spec.js @@ -1,52 +1,40 @@ -import Vue, { nextTick } from 'vue'; -import PromptComponent from '~/notebook/cells/prompt.vue'; - -const Component = Vue.extend(PromptComponent); +import { shallowMount } from '@vue/test-utils'; +import Prompt from '~/notebook/cells/prompt.vue'; describe('Prompt component', () => { - let vm; + let wrapper; + + const mountComponent = ({ type }) => shallowMount(Prompt, { propsData: { type, count: 1 } }); + + afterEach(() => { + wrapper.destroy(); + }); describe('input', () => { beforeEach(() => { - vm = new Component({ - propsData: { - type: 'In', - count: 1, - }, - }); - vm.$mount(); - - return nextTick(); + wrapper = mountComponent({ type: 'In' }); }); it('renders in label', () => { - expect(vm.$el.textContent.trim()).toContain('In'); + expect(wrapper.text()).toContain('In'); }); it('renders count', () => { - expect(vm.$el.textContent.trim()).toContain('1'); + expect(wrapper.text()).toContain('1'); }); }); describe('output', () => { beforeEach(() => { - vm = new Component({ - propsData: { - type: 'Out', - count: 1, - }, - }); - vm.$mount(); - - return nextTick(); + wrapper = mountComponent({ type: 'Out' }); }); it('renders in label', () => { - expect(vm.$el.textContent.trim()).toContain('Out'); + expect(wrapper.text()).toContain('Out'); }); it('renders count', () => { - expect(vm.$el.textContent.trim()).toContain('1'); + expect(wrapper.text()).toContain('1'); }); }); }); diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index 116016ecae2..463787c148b 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -550,98 +550,74 @@ describe('issue_comment_form component', () => { }); describe('confidential notes checkbox', () => { - describe('when confidentialNotes feature flag is `false`', () => { - const features = { confidentialNotes: false }; + it('should render checkbox as unchecked by default', () => { + mountComponent({ + mountFunction: mount, + initialData: { note: 'confidential note' }, + noteableData: { ...notableDataMockCanUpdateIssuable }, + }); - it('should not render checkbox', () => { + const checkbox = findConfidentialNoteCheckbox(); + expect(checkbox.exists()).toBe(true); + expect(checkbox.element.checked).toBe(false); + }); + + it.each` + noteableType | rendered | message + ${'Issue'} | ${true} | ${'render'} + ${'Epic'} | ${true} | ${'render'} + ${'MergeRequest'} | ${false} | ${'not render'} + `( + 'should $message checkbox when noteableType is $noteableType', + ({ noteableType, rendered }) => { mountComponent({ mountFunction: mount, - initialData: { note: 'confidential note' }, - noteableData: { ...notableDataMockCanUpdateIssuable }, - features, + noteableType, + initialData: { note: 'internal note' }, + noteableData: { ...notableDataMockCanUpdateIssuable, noteableType }, }); - const checkbox = findConfidentialNoteCheckbox(); - expect(checkbox.exists()).toBe(false); - }); - }); - - describe('when confidentialNotes feature flag is `true`', () => { - const features = { confidentialNotes: true }; + expect(findConfidentialNoteCheckbox().exists()).toBe(rendered); + }, + ); - it('should render checkbox as unchecked by default', () => { + describe.each` + shouldCheckboxBeChecked + ${true} + ${false} + `('when checkbox value is `$shouldCheckboxBeChecked`', ({ shouldCheckboxBeChecked }) => { + it(`sets \`confidential\` to \`${shouldCheckboxBeChecked}\``, async () => { mountComponent({ mountFunction: mount, initialData: { note: 'confidential note' }, noteableData: { ...notableDataMockCanUpdateIssuable }, - features, }); - const checkbox = findConfidentialNoteCheckbox(); - expect(checkbox.exists()).toBe(true); - expect(checkbox.element.checked).toBe(false); - }); + jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue({}); - it.each` - noteableType | rendered | message - ${'Issue'} | ${true} | ${'render'} - ${'Epic'} | ${true} | ${'render'} - ${'MergeRequest'} | ${false} | ${'not render'} - `( - 'should $message checkbox when noteableType is $noteableType', - ({ noteableType, rendered }) => { - mountComponent({ - mountFunction: mount, - noteableType, - initialData: { note: 'internal note' }, - noteableData: { ...notableDataMockCanUpdateIssuable, noteableType }, - features, - }); - - expect(findConfidentialNoteCheckbox().exists()).toBe(rendered); - }, - ); - - describe.each` - shouldCheckboxBeChecked - ${true} - ${false} - `('when checkbox value is `$shouldCheckboxBeChecked`', ({ shouldCheckboxBeChecked }) => { - it(`sets \`confidential\` to \`${shouldCheckboxBeChecked}\``, async () => { - mountComponent({ - mountFunction: mount, - initialData: { note: 'confidential note' }, - noteableData: { ...notableDataMockCanUpdateIssuable }, - features, - }); - - jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue({}); - - const checkbox = findConfidentialNoteCheckbox(); + const checkbox = findConfidentialNoteCheckbox(); - // check checkbox - checkbox.element.checked = shouldCheckboxBeChecked; - checkbox.trigger('change'); - await nextTick(); + // check checkbox + checkbox.element.checked = shouldCheckboxBeChecked; + checkbox.trigger('change'); + await nextTick(); - // submit comment - findCommentButton().trigger('click'); + // submit comment + findCommentButton().trigger('click'); - const [providedData] = wrapper.vm.saveNote.mock.calls[0]; - expect(providedData.data.note.confidential).toBe(shouldCheckboxBeChecked); - }); + const [providedData] = wrapper.vm.saveNote.mock.calls[0]; + expect(providedData.data.note.confidential).toBe(shouldCheckboxBeChecked); }); + }); - describe('when user cannot update issuable', () => { - it('should not render checkbox', () => { - mountComponent({ - mountFunction: mount, - noteableData: { ...notableDataMockCannotUpdateIssuable }, - features, - }); - - expect(findConfidentialNoteCheckbox().exists()).toBe(false); + describe('when user cannot update issuable', () => { + it('should not render checkbox', () => { + mountComponent({ + mountFunction: mount, + noteableData: { ...notableDataMockCannotUpdateIssuable }, }); + + expect(findConfidentialNoteCheckbox().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/notes/components/note_signed_out_widget_spec.js b/spec/frontend/notes/components/note_signed_out_widget_spec.js index e217a2caa73..84f20e4ad58 100644 --- a/spec/frontend/notes/components/note_signed_out_widget_spec.js +++ b/spec/frontend/notes/components/note_signed_out_widget_spec.js @@ -1,41 +1,30 @@ -import Vue from 'vue'; -import noteSignedOut from '~/notes/components/note_signed_out_widget.vue'; +import { shallowMount } from '@vue/test-utils'; +import NoteSignedOutWidget from '~/notes/components/note_signed_out_widget.vue'; import createStore from '~/notes/stores'; import { notesDataMock } from '../mock_data'; -describe('note_signed_out_widget component', () => { - let store; - let vm; +describe('NoteSignedOutWidget component', () => { + let wrapper; beforeEach(() => { - const Component = Vue.extend(noteSignedOut); - store = createStore(); + const store = createStore(); store.dispatch('setNotesData', notesDataMock); - - vm = new Component({ - store, - }).$mount(); + wrapper = shallowMount(NoteSignedOutWidget, { store }); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); - it('should render sign in link provided in the store', () => { - expect(vm.$el.querySelector(`a[href="${notesDataMock.newSessionPath}"]`).textContent).toEqual( - 'sign in', - ); + it('renders sign in link provided in the store', () => { + expect(wrapper.find(`a[href="${notesDataMock.newSessionPath}"]`).text()).toBe('sign in'); }); - it('should render register link provided in the store', () => { - expect(vm.$el.querySelector(`a[href="${notesDataMock.registerPath}"]`).textContent).toEqual( - 'register', - ); + it('renders register link provided in the store', () => { + expect(wrapper.find(`a[href="${notesDataMock.registerPath}"]`).text()).toBe('register'); }); - it('should render information text', () => { - expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toEqual( - 'Please register or sign in to reply', - ); + it('renders information text', () => { + expect(wrapper.text()).toContain('Please register or sign in to reply'); }); }); diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js index ddfa77117ca..603db56a098 100644 --- a/spec/frontend/notes/components/noteable_discussion_spec.js +++ b/spec/frontend/notes/components/noteable_discussion_spec.js @@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import discussionWithTwoUnresolvedNotes from 'test_fixtures/merge_requests/resolved_diff_discussion.json'; import { trimText } from 'helpers/text_helper'; -import mockDiffFile from 'jest/diffs/mock_data/diff_file'; +import { getDiffFileMock } from 'jest/diffs/mock_data/diff_file'; import DiscussionNotes from '~/notes/components/discussion_notes.vue'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; import ResolveWithIssueButton from '~/notes/components/discussion_resolve_with_issue_button.vue'; @@ -45,7 +45,7 @@ describe('noteable_discussion component', () => { it('should render thread header', async () => { const discussion = { ...discussionMock }; - discussion.diff_file = mockDiffFile; + discussion.diff_file = getDiffFileMock(); discussion.diff_discussion = true; discussion.expanded = false; @@ -57,7 +57,7 @@ describe('noteable_discussion component', () => { it('should hide actions when diff refs do not exists', async () => { const discussion = { ...discussionMock }; - discussion.diff_file = { ...mockDiffFile, diff_refs: null }; + discussion.diff_file = { ...getDiffFileMock(), diff_refs: null }; discussion.diff_discussion = true; discussion.expanded = false; diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js index 385edc59eb6..3350609bb90 100644 --- a/spec/frontend/notes/components/noteable_note_spec.js +++ b/spec/frontend/notes/components/noteable_note_spec.js @@ -1,20 +1,15 @@ import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; - +import { GlAvatar } from '@gitlab/ui'; import waitForPromises from 'helpers/wait_for_promises'; - import DiffsModule from '~/diffs/store/modules'; - import NoteActions from '~/notes/components/note_actions.vue'; import NoteBody from '~/notes/components/note_body.vue'; import NoteHeader from '~/notes/components/note_header.vue'; import issueNote from '~/notes/components/noteable_note.vue'; import NotesModule from '~/notes/stores/modules'; import { NOTEABLE_TYPE_MAPPING } from '~/notes/constants'; - -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; - import { noteableDataMock, notesDataMock, note } from '../mock_data'; Vue.use(Vuex); @@ -205,19 +200,21 @@ describe('issue_note', () => { await nextTick(); - expect(wrapper.findComponent(UserAvatarLink).props('imgSize')).toBe(24); + const avatar = wrapper.findComponent(GlAvatar); + const avatarProps = avatar.props(); + expect(avatarProps.size).toBe(24); }); }); - it('should render user information', () => { + it('should render user avatar', () => { const { author } = note; - const avatar = wrapper.findComponent(UserAvatarLink); + const avatar = wrapper.findComponent(GlAvatar); const avatarProps = avatar.props(); - expect(avatarProps.linkHref).toBe(author.path); - expect(avatarProps.imgSrc).toBe(author.avatar_url); - expect(avatarProps.imgAlt).toBe(author.name); - expect(avatarProps.imgSize).toBe(40); + expect(avatarProps.src).toBe(author.avatar_url); + expect(avatarProps.entityName).toBe(author.username); + expect(avatarProps.alt).toBe(author.name); + expect(avatarProps.size).toEqual({ default: 24, md: 32 }); }); it('should render note header content', () => { diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js index f4eb69e0d49..36a68118fa7 100644 --- a/spec/frontend/notes/components/notes_app_spec.js +++ b/spec/frontend/notes/components/notes_app_spec.js @@ -44,22 +44,6 @@ describe('note_app', () => { .wrappers.map((node) => (node.is(CommentForm) ? TYPE_COMMENT_FORM : TYPE_NOTES_LIST)); }; - /** - * waits for fetchNotes() to complete - */ - const waitForDiscussionsRequest = () => - new Promise((resolve) => { - const { vm } = wrapper.find(NotesApp); - const unwatch = vm.$watch('isFetching', (isFetching) => { - if (isFetching) { - return; - } - - unwatch(); - resolve(); - }); - }); - beforeEach(() => { $('body').attr('data-page', 'projects:merge_requests:show'); @@ -95,7 +79,7 @@ describe('note_app', () => { axiosMock.onAny().reply(200, []); wrapper = mountComponent(); - return waitForDiscussionsRequest(); + return waitForPromises(); }); afterEach(() => { @@ -129,7 +113,7 @@ describe('note_app', () => { axiosMock.onAny().reply(mockData.getIndividualNoteResponse); wrapper = mountComponent(); - return waitForDiscussionsRequest(); + return waitForPromises(); }); afterEach(() => { @@ -172,7 +156,7 @@ describe('note_app', () => { axiosMock.onAny().reply(mockData.getIndividualNoteResponse); store.state.commentsDisabled = true; wrapper = mountComponent(); - return waitForDiscussionsRequest(); + return waitForPromises(); }); afterEach(() => { @@ -197,7 +181,7 @@ describe('note_app', () => { store.state.isTimelineEnabled = true; wrapper = mountComponent(); - return waitForDiscussionsRequest(); + return waitForPromises(); }); afterEach(() => { @@ -210,15 +194,13 @@ describe('note_app', () => { }); describe('while fetching data', () => { - beforeEach(() => { + beforeEach(async () => { setHTMLFixture('<div class="js-discussions-count"></div>'); - axiosMock.onAny().reply(200, []); wrapper = mountComponent(); }); afterEach(() => { - waitForDiscussionsRequest(); - resetHTMLFixture(); + return waitForPromises().then(() => resetHTMLFixture()); }); it('renders skeleton notes', () => { @@ -242,7 +224,7 @@ describe('note_app', () => { beforeEach(() => { axiosMock.onAny().reply(mockData.getIndividualNoteResponse); wrapper = mountComponent(); - return waitForDiscussionsRequest().then(() => { + return waitForPromises().then(() => { wrapper.find('.js-note-edit').trigger('click'); }); }); @@ -264,7 +246,7 @@ describe('note_app', () => { beforeEach(() => { axiosMock.onAny().reply(mockData.getDiscussionNoteResponse); wrapper = mountComponent(); - return waitForDiscussionsRequest().then(() => { + return waitForPromises().then(() => { wrapper.find('.js-note-edit').trigger('click'); }); }); @@ -287,7 +269,7 @@ describe('note_app', () => { beforeEach(() => { axiosMock.onAny().reply(mockData.getIndividualNoteResponse); wrapper = mountComponent(); - return waitForDiscussionsRequest(); + return waitForPromises(); }); it('should render markdown docs url', () => { @@ -309,7 +291,7 @@ describe('note_app', () => { beforeEach(() => { axiosMock.onAny().reply(mockData.getIndividualNoteResponse); wrapper = mountComponent(); - return waitForDiscussionsRequest(); + return waitForPromises(); }); it('should render markdown docs url', async () => { @@ -337,7 +319,7 @@ describe('note_app', () => { beforeEach(() => { axiosMock.onAny().reply(200, []); wrapper = mountComponent(); - return waitForDiscussionsRequest(); + return waitForPromises(); }); it('dispatches toggleAward after toggleAward event', () => { @@ -373,7 +355,7 @@ describe('note_app', () => { beforeEach(() => { axiosMock.onAny().reply(mockData.getIndividualNoteResponse); wrapper = mountComponent(); - return waitForDiscussionsRequest(); + return waitForPromises(); }); it('should listen hashchange event', () => { @@ -471,7 +453,7 @@ describe('note_app', () => { wrapper = shallowMount(NotesApp, { propsData, store: createStore() }); await waitForPromises(); - expect(axiosMock.history.get[0].params).toBeUndefined(); + expect(axiosMock.history.get[0].params).toEqual({ per_page: 20 }); }); }); @@ -496,14 +478,14 @@ describe('note_app', () => { wrapper = mountWithNotesFilter(undefined); await waitForPromises(); - expect(axiosMock.history.get[0].params).toBeUndefined(); + expect(axiosMock.history.get[0].params).toEqual({ per_page: 20 }); }); it('does not include extra query params when filter is already set to default', async () => { wrapper = mountWithNotesFilter(constants.DISCUSSION_FILTERS_DEFAULT_VALUE); await waitForPromises(); - expect(axiosMock.history.get[0].params).toBeUndefined(); + expect(axiosMock.history.get[0].params).toEqual({ per_page: 20 }); }); it('includes extra query params when filter is not set to default', async () => { @@ -512,6 +494,7 @@ describe('note_app', () => { expect(axiosMock.history.get[0].params).toEqual({ notes_filter: constants.DISCUSSION_FILTERS_DEFAULT_VALUE, + per_page: 20, persist_filter: false, }); }); diff --git a/spec/frontend/notes/components/toggle_replies_widget_spec.js b/spec/frontend/notes/components/toggle_replies_widget_spec.js index 409e1bc3951..8c3696e88b7 100644 --- a/spec/frontend/notes/components/toggle_replies_widget_spec.js +++ b/spec/frontend/notes/components/toggle_replies_widget_spec.js @@ -1,13 +1,14 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import toggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import ToggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import { note } from '../mock_data'; -const deepCloneObject = (obj) => JSON.parse(JSON.stringify(obj)); - describe('toggle replies widget for notes', () => { - let vm; - let ToggleRepliesWidget; + let wrapper; + + const deepCloneObject = (obj) => JSON.parse(JSON.stringify(obj)); + const noteFromOtherUser = deepCloneObject(note); noteFromOtherUser.author.username = 'fatihacet'; @@ -17,62 +18,62 @@ describe('toggle replies widget for notes', () => { const replies = [note, note, note, noteFromOtherUser, noteFromAnotherUser]; - beforeEach(() => { - ToggleRepliesWidget = Vue.extend(toggleRepliesWidget); - }); + const findCollapseToggleButton = () => + wrapper.findByRole('button', { text: ToggleRepliesWidget.i18n.collapseReplies }); + const findExpandToggleButton = () => + wrapper.findByRole('button', { text: ToggleRepliesWidget.i18n.expandReplies }); + const findRepliesButton = () => wrapper.findByRole('button', { text: '5 replies' }); + const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip); + const findUserAvatarLink = () => wrapper.findAllComponents(UserAvatarLink); + const findUserLink = () => wrapper.findByRole('link', { text: noteFromAnotherUser.author.name }); + + const mountComponent = ({ collapsed = false }) => + mountExtended(ToggleRepliesWidget, { propsData: { replies, collapsed } }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); describe('collapsed state', () => { beforeEach(() => { - vm = mountComponent(ToggleRepliesWidget, { - replies, - collapsed: true, - }); + wrapper = mountComponent({ collapsed: true }); }); - it('should render the collapsed', () => { - const vmTextContent = vm.$el.textContent.replace(/\s\s+/g, ' '); - - expect(vm.$el.classList.contains('collapsed')).toEqual(true); - expect(vm.$el.querySelectorAll('.user-avatar-link').length).toEqual(3); - expect(vm.$el.querySelector('time')).not.toBeNull(); - expect(vmTextContent).toContain('5 replies'); - expect(vmTextContent).toContain(`Last reply by ${noteFromAnotherUser.author.name}`); + it('renders collapsed state elements', () => { + expect(findExpandToggleButton().exists()).toBe(true); + expect(findUserAvatarLink()).toHaveLength(3); + expect(findRepliesButton().exists()).toBe(true); + expect(wrapper.text()).toContain('Last reply by'); + expect(findUserLink().exists()).toBe(true); + expect(findTimeAgoTooltip().exists()).toBe(true); }); - it('should emit toggle event when the replies text clicked', () => { - const spy = jest.spyOn(vm, '$emit'); + it('emits "toggle" event when expand toggle button is clicked', () => { + findExpandToggleButton().trigger('click'); + + expect(wrapper.emitted('toggle')).toEqual([[]]); + }); - vm.$el.querySelector('.js-replies-text').click(); + it('emits "toggle" event when replies button is clicked', () => { + findRepliesButton().trigger('click'); - expect(spy).toHaveBeenCalledWith('toggle'); + expect(wrapper.emitted('toggle')).toEqual([[]]); }); }); describe('expanded state', () => { beforeEach(() => { - vm = mountComponent(ToggleRepliesWidget, { - replies, - collapsed: false, - }); + wrapper = mountComponent({ collapsed: false }); }); - it('should render expanded state', () => { - const vmTextContent = vm.$el.textContent.replace(/\s\s+/g, ' '); - - expect(vm.$el.querySelector('.collapse-replies-btn')).not.toBeNull(); - expect(vmTextContent).toContain('Collapse replies'); + it('renders expanded state elements', () => { + expect(findCollapseToggleButton().exists()).toBe(true); }); - it('should emit toggle event when the collapse replies text called', () => { - const spy = jest.spyOn(vm, '$emit'); - - vm.$el.querySelector('.js-collapse-replies').click(); + it('emits "toggle" event when collapse toggle button is clicked', () => { + findCollapseToggleButton().trigger('click'); - expect(spy).toHaveBeenCalledWith('toggle'); + expect(wrapper.emitted('toggle')).toEqual([[]]); }); }); }); diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 38f29ac2559..02b27eca196 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -15,6 +15,7 @@ import * as utils from '~/notes/stores/utils'; import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql'; import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql'; import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub'; +import waitForPromises from 'helpers/wait_for_promises'; import { resetStore } from '../helpers'; import { discussionMock, @@ -254,9 +255,7 @@ describe('Actions Notes Store', () => { jest.advanceTimersByTime(time); } - return new Promise((resolve) => { - requestAnimationFrame(resolve); - }); + return waitForPromises(); }; const advanceXMoreIntervals = async (number) => { const timeoutLength = pollInterval * number; @@ -365,7 +364,6 @@ describe('Actions Notes Store', () => { }); it('hides the error display if it exists on success', async () => { - jest.mock(); failureMock(); await startPolling(); @@ -668,7 +666,6 @@ describe('Actions Notes Store', () => { describe('updateOrCreateNotes', () => { it('Prevents `fetchDiscussions` being called multiple times within time limit', () => { - jest.useFakeTimers(); const note = { id: 1234, type: notesConstants.DIFF_NOTE }; const getters = { notesById: {} }; state = { discussions: [note], notesData: { discussionsPath: '' } }; @@ -1351,7 +1348,7 @@ describe('Actions Notes Store', () => { return testAction( actions.fetchDiscussions, {}, - null, + { noteableType: notesConstants.MERGE_REQUEST_NOTEABLE_TYPE }, [ { type: mutationTypes.ADD_OR_UPDATE_DISCUSSIONS, payload: { discussion } }, { type: mutationTypes.SET_FETCHING_DISCUSSIONS, payload: false }, @@ -1360,13 +1357,11 @@ describe('Actions Notes Store', () => { ); }); - it('dispatches `fetchDiscussionsBatch` action if `paginatedIssueDiscussions` feature flag is enabled', () => { - window.gon = { features: { paginatedIssueDiscussions: true } }; - + it('dispatches `fetchDiscussionsBatch` action if noteable is an Issue', () => { return testAction( actions.fetchDiscussions, { path: 'test-path', filter: 'test-filter', persistFilter: 'test-persist-filter' }, - null, + { noteableType: notesConstants.ISSUE_NOTEABLE_TYPE }, [], [ { @@ -1389,7 +1384,7 @@ describe('Actions Notes Store', () => { return testAction( actions.fetchDiscussions, { path: 'test-path', filter: 'test-filter', persistFilter: 'test-persist-filter' }, - null, + { noteableType: notesConstants.MERGE_REQUEST_NOTEABLE_TYPE }, [], [ { 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 ca666e38291..9982286c625 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 @@ -18,7 +18,6 @@ import { CLEANUP_SCHEDULED_TOOLTIP, CLEANUP_ONGOING_TOOLTIP, CLEANUP_UNFINISHED_TOOLTIP, - ROOT_IMAGE_TEXT, ROOT_IMAGE_TOOLTIP, } from '~/packages_and_registries/container_registry/explorer/constants'; import getContainerRepositoryMetadata from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_metadata.query.graphql'; @@ -35,6 +34,7 @@ describe('Details Header', () => { canDelete: true, project: { visibility: 'public', + path: 'path', containerExpirationPolicy: { enabled: false, }, @@ -98,8 +98,8 @@ describe('Details Header', () => { return waitForPromises(); }); - it('root image ', () => { - expect(findTitle().text()).toBe(ROOT_IMAGE_TEXT); + it('root image shows project path name', () => { + expect(findTitle().text()).toBe('path'); }); it('has an icon', () => { diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js index 0581a40b6a2..a5b2b1d7cf8 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js @@ -109,5 +109,17 @@ describe('cleanup_status', () => { expect(findPopover().findComponent(GlLink).exists()).toBe(true); expect(findPopover().findComponent(GlLink).attributes('href')).toBe(cleanupPolicyHelpPage); }); + + it('id matches popover target attribute', () => { + mountComponent({ + status: UNFINISHED_STATUS, + next_run_at: '2063-04-08T01:44:03Z', + }); + + const id = findExtraInfoIcon().attributes('id'); + + expect(id).toMatch(/status-info-[0-9]+/); + expect(findPopover().props('target')).toEqual(id); + }); }); }); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js index 979e1500d7d..d12933526bc 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js @@ -1,6 +1,7 @@ -import { GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui'; +import { GlIcon, GlSprintf, GlSkeletonLoader, GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { mockTracking } from 'helpers/tracking_helper'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import DeleteButton from '~/packages_and_registries/container_registry/explorer/components/delete_button.vue'; import CleanupStatus from '~/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue'; @@ -12,7 +13,6 @@ import { IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_MIGRATING_STATE, SCHEDULED_STATUS, - ROOT_IMAGE_TEXT, COPY_IMAGE_PATH_TITLE, } from '~/packages_and_registries/container_registry/explorer/constants'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -31,13 +31,15 @@ describe('Image List Row', () => { const findCleanupStatus = () => wrapper.findComponent(CleanupStatus); const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const findListItemComponent = () => wrapper.findComponent(ListItem); + const findShowFullPathButton = () => wrapper.findComponent(GlButton); - const mountComponent = (props) => { + const mountComponent = (props, features = {}) => { wrapper = shallowMount(Component, { stubs: { RouterLink, GlSprintf, ListItem, + GlButton, }, propsData: { item, @@ -45,6 +47,9 @@ describe('Image List Row', () => { }, provide: { config: {}, + glFeatures: { + ...features, + }, }, directives: { GlTooltip: createMockDirective(), @@ -96,10 +101,10 @@ describe('Image List Row', () => { }); }); - it(`when the image has no name appends ${ROOT_IMAGE_TEXT} to the path`, () => { + it('when the image has no name lists the path', () => { mountComponent({ item: { ...item, name: '' } }); - expect(findDetailsLink().text()).toBe(`${item.path}/ ${ROOT_IMAGE_TEXT}`); + expect(findDetailsLink().text()).toBe(item.path); }); it('contains a clipboard button', () => { @@ -144,6 +149,35 @@ describe('Image List Row', () => { expect(findClipboardButton().attributes('disabled')).toBe('true'); }); }); + + describe('when containerRegistryShowShortenedPath feature enabled', () => { + let trackingSpy; + + beforeEach(() => { + mountComponent({}, { containerRegistryShowShortenedPath: true }); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + it('renders shortened name of image', () => { + expect(findShowFullPathButton().exists()).toBe(true); + expect(findDetailsLink().text()).toBe('gitlab-test/rails-12009'); + }); + + it('clicking on shortened name of image hides the button & shows full path', async () => { + const btn = findShowFullPathButton(); + const mockFocusFn = jest.fn(); + wrapper.vm.$refs.imageName.$el.focus = mockFocusFn; + + await btn.trigger('click'); + + expect(findShowFullPathButton().exists()).toBe(false); + expect(findDetailsLink().text()).toBe(item.path); + expect(mockFocusFn).toHaveBeenCalled(); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_show_full_path', { + label: 'registry_image_list', + }); + }); + }); }); describe('delete button', () => { diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js index 7e6f88fe5bc..f9739509ef9 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js @@ -11,6 +11,10 @@ export const imagesListResponse = [ createdAt: '2020-11-03T13:29:21Z', expirationPolicyStartedAt: null, expirationPolicyCleanupStatus: 'UNSCHEDULED', + project: { + id: 'gid://gitlab/Project/22', + path: 'gitlab-test', + }, }, { __typename: 'ContainerRepository', @@ -24,6 +28,10 @@ export const imagesListResponse = [ createdAt: '2020-09-21T06:57:43Z', expirationPolicyStartedAt: null, expirationPolicyCleanupStatus: 'UNSCHEDULED', + project: { + id: 'gid://gitlab/Project/22', + path: 'gitlab-test', + }, }, ]; 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 59ca47bee50..1d161888a4d 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 @@ -20,7 +20,6 @@ import { ALERT_DANGER_IMAGE, ALERT_DANGER_IMPORTING, 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'; @@ -482,7 +481,7 @@ describe('Details Page', () => { expect(breadCrumbState.updateName).toHaveBeenCalledWith(MISSING_OR_DELETED_IMAGE_BREADCRUMB); }); - it(`when the image has no name set the breadcrumb to ${ROOT_IMAGE_TEXT}`, async () => { + it(`when the image has no name set the breadcrumb to project name`, async () => { mountComponent({ resolver: jest .fn() @@ -491,7 +490,7 @@ describe('Details Page', () => { await waitForApolloRequestRender(); - expect(breadCrumbState.updateName).toHaveBeenCalledWith(ROOT_IMAGE_TEXT); + expect(breadCrumbState.updateName).toHaveBeenCalledWith('gitlab-test'); }); }); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js index fe4a2c06f1c..f2901148e17 100644 --- a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js +++ b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js @@ -38,6 +38,8 @@ const dummyGon = { let originalGon; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${dummyGrouptId}/dependency_proxy/cache`; +Vue.use(VueApollo); + describe('DependencyProxyApp', () => { let wrapper; let apolloProvider; @@ -51,8 +53,6 @@ describe('DependencyProxyApp', () => { }; function createComponent({ provide = provideDefaults } = {}) { - Vue.use(VueApollo); - const requestHandlers = [[getDependencyProxyDetailsQuery, resolver]]; apolloProvider = createMockApollo(requestHandlers); @@ -103,19 +103,21 @@ describe('DependencyProxyApp', () => { describe('when the dependency proxy is available', () => { describe('when is loading', () => { - beforeEach(() => { + it('renders the skeleton loader', () => { createComponent(); - }); - it('renders the skeleton loader', () => { expect(findSkeletonLoader().exists()).toBe(true); }); it('does not render a form group with label', () => { + createComponent(); + expect(findFormGroup().exists()).toBe(false); }); it('does not show the main section', () => { + createComponent(); + expect(findMainArea().exists()).toBe(false); }); }); @@ -215,23 +217,26 @@ describe('DependencyProxyApp', () => { }); describe('triggering page event on list', () => { - beforeEach(async () => { + it('re-renders the skeleton loader', async () => { findManifestList().vm.$emit('next-page'); - await nextTick(); - }); - it('re-renders the skeleton loader', () => { expect(findSkeletonLoader().exists()).toBe(true); }); - it('renders form group with label', () => { + it('renders form group with label', async () => { + findManifestList().vm.$emit('next-page'); + await nextTick(); + expect(findFormGroup().attributes('label')).toEqual( expect.stringMatching(DependencyProxyApp.i18n.proxyImagePrefix), ); }); - it('does not show the main section', () => { + it('does not show the main section', async () => { + findManifestList().vm.$emit('next-page'); + await nextTick(); + expect(findMainArea().exists()).toBe(false); }); }); diff --git a/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js index e60989b0949..9d4c7f4737b 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js @@ -6,13 +6,15 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import component from '~/packages_and_registries/settings/group/components/dependency_proxy_settings.vue'; -import { DEPENDENCY_PROXY_HEADER } from '~/packages_and_registries/settings/group/constants'; +import { + DEPENDENCY_PROXY_HEADER, + DEPENDENCY_PROXY_DESCRIPTION, +} from '~/packages_and_registries/settings/group/constants'; import updateDependencyProxySettings from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql'; import updateDependencyProxyImageTtlGroupPolicy from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_image_ttl_group_policy.mutation.graphql'; import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql'; import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; -import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue'; import { updateGroupDependencyProxySettingsOptimisticResponse, updateDependencyProxyImageTtlGroupPolicyOptimisticResponse, @@ -36,7 +38,6 @@ describe('DependencyProxySettings', () => { let updateTtlPoliciesMutationResolver; const defaultProvide = { - defaultExpanded: false, groupPath: 'foo_group_path', groupDependencyProxyPath: 'group_dependency_proxy_path', }; @@ -86,7 +87,6 @@ describe('DependencyProxySettings', () => { }); const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); - const findSettingsTitles = () => wrapper.findComponent(SettingsTitles); const findEnableProxyToggle = () => wrapper.findByTestId('dependency-proxy-setting-toggle'); const findEnableTtlPoliciesToggle = () => wrapper.findByTestId('dependency-proxy-ttl-policies-toggle'); @@ -108,16 +108,11 @@ describe('DependencyProxySettings', () => { expect(findSettingsBlock().exists()).toBe(true); }); - it('passes the correct props to settings block', () => { - mountComponent(); - - expect(findSettingsBlock().props('defaultExpanded')).toBe(false); - }); - - it('has the correct header text', () => { + it('has the correct header text and description', () => { mountComponent(); expect(wrapper.text()).toContain(DEPENDENCY_PROXY_HEADER); + expect(wrapper.text()).toContain(DEPENDENCY_PROXY_DESCRIPTION); }); describe('enable toggle', () => { @@ -158,14 +153,6 @@ describe('DependencyProxySettings', () => { }); describe('storage settings', () => { - it('the component has the settings title', () => { - mountComponent(); - - expect(findSettingsTitles().props()).toMatchObject({ - title: component.i18n.storageSettingsTitle, - }); - }); - describe('enable proxy ttl policies', () => { it('exists', () => { mountComponent(); diff --git a/spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js index 79c2f811c08..3eecdeb5b1f 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js @@ -4,8 +4,6 @@ import component from '~/packages_and_registries/settings/group/components/dupli import { DUPLICATES_TOGGLE_LABEL, - DUPLICATES_ALLOWED_ENABLED, - DUPLICATES_ALLOWED_DISABLED, DUPLICATES_SETTING_EXCEPTION_TITLE, DUPLICATES_SETTINGS_EXCEPTION_LEGEND, } from '~/packages_and_registries/settings/group/constants'; @@ -36,7 +34,6 @@ describe('Duplicates Settings', () => { }); const findToggle = () => wrapper.findComponent(GlToggle); - const findToggleLabel = () => wrapper.find('[data-testid="toggle-label"'); const findInputGroup = () => wrapper.findComponent(GlFormGroup); const findInput = () => wrapper.findComponent(GlFormInput); @@ -47,7 +44,7 @@ describe('Duplicates Settings', () => { expect(findToggle().exists()).toBe(true); expect(findToggle().props()).toMatchObject({ label: DUPLICATES_TOGGLE_LABEL, - value: defaultProps.duplicatesAllowed, + value: !defaultProps.duplicatesAllowed, }); }); @@ -57,18 +54,11 @@ describe('Duplicates Settings', () => { findToggle().vm.$emit('change', false); expect(wrapper.emitted('update')).toStrictEqual([ - [{ [defaultProps.modelNames.allowed]: false }], + [{ [defaultProps.modelNames.allowed]: true }], ]); }); describe('when the duplicates are disabled', () => { - it('the toggle has the disabled message', () => { - mountComponent(); - - expect(findToggleLabel().exists()).toBe(true); - expect(findToggleLabel().text()).toMatchInterpolatedText(DUPLICATES_ALLOWED_DISABLED); - }); - it('shows a form group with an input field', () => { mountComponent(); @@ -130,13 +120,6 @@ describe('Duplicates Settings', () => { }); describe('when the duplicates are enabled', () => { - it('has the correct toggle label', () => { - mountComponent({ ...defaultProps, duplicatesAllowed: true }); - - expect(findToggleLabel().exists()).toBe(true); - expect(findToggleLabel().text()).toMatchInterpolatedText(DUPLICATES_ALLOWED_ENABLED); - }); - it('hides the form input group', () => { mountComponent({ ...defaultProps, duplicatesAllowed: true }); diff --git a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js index 635195ff0a4..31fc3ad419c 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js @@ -26,7 +26,6 @@ describe('Group Settings App', () => { let show; const defaultProvide = { - defaultExpanded: false, groupPath: 'foo_group_path', }; diff --git a/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js index d92d42e7834..274930ce668 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js @@ -1,4 +1,3 @@ -import { GlSprintf, GlLink } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -11,7 +10,6 @@ import MavenSettings from '~/packages_and_registries/settings/group/components/m import { PACKAGE_SETTINGS_HEADER, PACKAGE_SETTINGS_DESCRIPTION, - PACKAGES_DOCS_PATH, } from '~/packages_and_registries/settings/group/constants'; import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql'; @@ -33,7 +31,6 @@ describe('Packages Settings', () => { let apolloProvider; const defaultProvide = { - defaultExpanded: false, groupPath: 'foo_group_path', }; @@ -53,7 +50,6 @@ describe('Packages Settings', () => { packageSettings: packageSettings(), }, stubs: { - GlSprintf, SettingsBlock, MavenSettings, GenericSettings, @@ -67,7 +63,6 @@ describe('Packages Settings', () => { const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); const findDescription = () => wrapper.findByTestId('description'); - const findLink = () => wrapper.findComponent(GlLink); const findMavenSettings = () => wrapper.findComponent(MavenSettings); const findMavenDuplicatedSettings = () => findMavenSettings().findComponent(DuplicatesSettings); const findGenericSettings = () => wrapper.findComponent(GenericSettings); @@ -97,12 +92,6 @@ describe('Packages Settings', () => { expect(findSettingsBlock().exists()).toBe(true); }); - it('passes the correct props to settings block', () => { - mountComponent(); - - expect(findSettingsBlock().props('defaultExpanded')).toBe(false); - }); - it('has the correct header text', () => { mountComponent(); @@ -115,16 +104,6 @@ describe('Packages Settings', () => { expect(findDescription().text()).toMatchInterpolatedText(PACKAGE_SETTINGS_DESCRIPTION); }); - it('has the correct link', () => { - mountComponent(); - - expect(findLink().attributes()).toMatchObject({ - href: PACKAGES_DOCS_PATH, - target: '_blank', - }); - expect(findLink().text()).toBe('Learn more.'); - }); - describe('maven settings', () => { it('exists', () => { mountComponent(); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/container_expiration_policy_form_spec.js.snap b/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/container_expiration_policy_form_spec.js.snap index faa313118f3..108d9478788 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/container_expiration_policy_form_spec.js.snap +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/container_expiration_policy_form_spec.js.snap @@ -4,6 +4,7 @@ exports[`Container Expiration Policy Settings Form Cadence matches snapshot 1`] <expiration-dropdown-stub class="gl-mr-7 gl-mb-0!" data-testid="cadence-dropdown" + description="" formoptions="[object Object],[object Object],[object Object],[object Object],[object Object]" label="Run cleanup:" name="cadence" @@ -22,6 +23,7 @@ exports[`Container Expiration Policy Settings Form Enable matches snapshot 1`] = exports[`Container Expiration Policy Settings Form Keep N matches snapshot 1`] = ` <expiration-dropdown-stub data-testid="keep-n-dropdown" + description="" formoptions="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]" label="Keep the most recent:" name="keep-n" @@ -44,6 +46,7 @@ exports[`Container Expiration Policy Settings Form Keep Regex matches snapshot 1 exports[`Container Expiration Policy Settings Form OlderThan matches snapshot 1`] = ` <expiration-dropdown-stub data-testid="older-than-dropdown" + description="" formoptions="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]" label="Remove tags older than:" name="older-than" diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js index aa3506771fa..d83c717da6a 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js @@ -43,11 +43,6 @@ describe('Container expiration policy project settings', () => { GlSprintf, SettingsBlock, }, - mocks: { - $toast: { - show: jest.fn(), - }, - }, provide, ...config, }); @@ -98,7 +93,7 @@ describe('Container expiration policy project settings', () => { await waitForPromises(); expect(findFormComponent().exists()).toBe(true); - expect(findSettingsBlock().props('collapsible')).toBe(false); + expect(findSettingsBlock().exists()).toBe(true); }); describe('the form is disabled', () => { diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js index 5c9ade7f785..8b99ac6b06c 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js @@ -16,6 +16,7 @@ describe('ExpirationDropdown', () => { const findFormSelect = () => wrapper.find(GlFormSelect); const findFormGroup = () => wrapper.find(GlFormGroup); + const findDescription = () => wrapper.find('[data-testid="description"]'); const findOptions = () => wrapper.findAll('[data-testid="option"]'); const mountComponent = (props) => { @@ -47,6 +48,14 @@ describe('ExpirationDropdown', () => { expect(findOptions()).toHaveLength(defaultProps.formOptions.length); }); + + it('renders the description if passed', () => { + mountComponent({ + description: 'test description', + }); + + expect(findDescription().html()).toContain('test description'); + }); }); describe('model', () => { diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js new file mode 100644 index 00000000000..86f45d78bae --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js @@ -0,0 +1,267 @@ +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { GlLoadingIcon } from 'jest/packages_and_registries/shared/stubs'; +import component from '~/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue'; +import { + UPDATE_SETTINGS_ERROR_MESSAGE, + UPDATE_SETTINGS_SUCCESS_MESSAGE, + KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL, + KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION, +} from '~/packages_and_registries/settings/project/constants'; +import updatePackagesCleanupPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql'; +import Tracking from '~/tracking'; +import { packagesCleanupPolicyPayload, packagesCleanupPolicyMutationPayload } from '../mock_data'; + +Vue.use(VueApollo); + +describe('Packages Cleanup Policy Settings Form', () => { + let wrapper; + let fakeApollo; + + const defaultProvidedValues = { + projectPath: 'path', + }; + + const { + data: { + project: { packagesCleanupPolicy }, + }, + } = packagesCleanupPolicyPayload(); + + const defaultProps = { + value: { ...packagesCleanupPolicy }, + }; + + const trackingPayload = { + label: 'packages_cleanup_policies', + }; + + const findForm = () => wrapper.find({ ref: 'form-element' }); + const findSaveButton = () => wrapper.findByTestId('save-button'); + const findKeepNDuplicatedPackageFilesDropdown = () => + wrapper.findByTestId('keep-n-duplicated-package-files-dropdown'); + + const submitForm = async () => { + findForm().trigger('submit'); + return waitForPromises(); + }; + + const mountComponent = ({ + props = defaultProps, + data, + config, + provide = defaultProvidedValues, + } = {}) => { + wrapper = shallowMountExtended(component, { + stubs: { + GlLoadingIcon, + }, + propsData: { ...props }, + provide, + data() { + return { + ...data, + }; + }, + mocks: { + $toast: { + show: jest.fn(), + }, + }, + ...config, + }); + }; + + const mountComponentWithApollo = ({ + provide = defaultProvidedValues, + mutationResolver, + queryPayload = packagesCleanupPolicyPayload(), + } = {}) => { + const requestHandlers = [[updatePackagesCleanupPolicyMutation, mutationResolver]]; + + fakeApollo = createMockApollo(requestHandlers); + + const { + data: { + project: { packagesCleanupPolicy: value }, + }, + } = queryPayload; + + mountComponent({ + provide, + props: { + ...defaultProps, + value, + }, + config: { + apolloProvider: fakeApollo, + }, + }); + }; + + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + }); + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + describe('keepNDuplicatedPackageFiles', () => { + it('renders dropdown', () => { + mountComponent(); + + const element = findKeepNDuplicatedPackageFilesDropdown(); + + expect(element.exists()).toBe(true); + expect(element.props('label')).toMatchInterpolatedText(KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL); + expect(element.props('description')).toEqual(KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION); + }); + + it('input event triggers a model update', () => { + mountComponent(); + + findKeepNDuplicatedPackageFilesDropdown().vm.$emit('input', 'foo'); + expect(wrapper.emitted('input')[0][0]).toMatchObject({ + keepNDuplicatedPackageFiles: 'foo', + }); + }); + + it('shows the default option when none are selected', () => { + mountComponent({ props: { value: {} } }); + expect(findKeepNDuplicatedPackageFilesDropdown().props('value')).toEqual('ALL_PACKAGE_FILES'); + }); + + it.each` + isLoading | mutationLoading + ${true} | ${false} + ${true} | ${true} + ${false} | ${true} + `( + 'is disabled when is loading is $isLoading and mutationLoading is $mutationLoading', + ({ isLoading, mutationLoading }) => { + mountComponent({ + props: { isLoading, value: {} }, + data: { mutationLoading }, + }); + expect(findKeepNDuplicatedPackageFilesDropdown().props('disabled')).toEqual(true); + }, + ); + + it('has the correct formOptions', () => { + mountComponent(); + expect(findKeepNDuplicatedPackageFilesDropdown().props('formOptions')).toEqual( + wrapper.vm.$options.formOptions.keepNDuplicatedPackageFiles, + ); + }); + }); + + describe('form', () => { + describe('actions', () => { + describe('submit button', () => { + it('has type submit', () => { + mountComponent(); + + expect(findSaveButton().attributes('type')).toBe('submit'); + }); + + it.each` + isLoading | mutationLoading | disabled + ${true} | ${true} | ${true} + ${true} | ${false} | ${true} + ${false} | ${true} | ${true} + ${false} | ${false} | ${false} + `( + 'when isLoading is $isLoading and mutationLoading is $mutationLoading is disabled', + ({ isLoading, mutationLoading, disabled }) => { + mountComponent({ + props: { ...defaultProps, isLoading }, + data: { mutationLoading }, + }); + + expect(findSaveButton().props('disabled')).toBe(disabled); + expect(findKeepNDuplicatedPackageFilesDropdown().props('disabled')).toBe(disabled); + }, + ); + + it.each` + isLoading | mutationLoading | showLoading + ${true} | ${true} | ${true} + ${true} | ${false} | ${true} + ${false} | ${true} | ${true} + ${false} | ${false} | ${false} + `( + 'when isLoading is $isLoading and mutationLoading is $mutationLoading is $showLoading that the loading icon is shown', + ({ isLoading, mutationLoading, showLoading }) => { + mountComponent({ + props: { ...defaultProps, isLoading }, + data: { mutationLoading }, + }); + + expect(findSaveButton().props('loading')).toBe(showLoading); + }, + ); + }); + }); + + describe('form submit event', () => { + it('dispatches the correct apollo mutation', () => { + const mutationResolver = jest + .fn() + .mockResolvedValue(packagesCleanupPolicyMutationPayload()); + mountComponentWithApollo({ + mutationResolver, + }); + + findForm().trigger('submit'); + + expect(mutationResolver).toHaveBeenCalledWith({ + input: { + keepNDuplicatedPackageFiles: 'ALL_PACKAGE_FILES', + projectPath: 'path', + }, + }); + }); + + it('tracks the submit event', () => { + mountComponentWithApollo({ + mutationResolver: jest.fn().mockResolvedValue(packagesCleanupPolicyMutationPayload()), + }); + + findForm().trigger('submit'); + + expect(Tracking.event).toHaveBeenCalledWith( + undefined, + 'submit_packages_cleanup_form', + trackingPayload, + ); + }); + + it('show a success toast when submit succeed', async () => { + mountComponentWithApollo({ + mutationResolver: jest.fn().mockResolvedValue(packagesCleanupPolicyMutationPayload()), + }); + + await submitForm(); + + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_SUCCESS_MESSAGE); + }); + + describe('when submit fails', () => { + it('shows an error', async () => { + mountComponentWithApollo({ + mutationResolver: jest.fn().mockRejectedValue(packagesCleanupPolicyMutationPayload()), + }); + + await submitForm(); + + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_ERROR_MESSAGE); + }); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_spec.js new file mode 100644 index 00000000000..6dfeeca6862 --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_spec.js @@ -0,0 +1,81 @@ +import { GlAlert, GlSprintf } 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 waitForPromises from 'helpers/wait_for_promises'; +import component from '~/packages_and_registries/settings/project/components/packages_cleanup_policy.vue'; +import PackagesCleanupPolicyForm from '~/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue'; +import { FETCH_SETTINGS_ERROR_MESSAGE } from '~/packages_and_registries/settings/project/constants'; +import packagesCleanupPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; + +import { packagesCleanupPolicyPayload, packagesCleanupPolicyData } from '../mock_data'; + +Vue.use(VueApollo); + +describe('Packages cleanup policy project settings', () => { + let wrapper; + let fakeApollo; + + const defaultProvidedValues = { + projectPath: 'path', + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findFormComponent = () => wrapper.findComponent(PackagesCleanupPolicyForm); + const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); + + const mountComponent = (provide = defaultProvidedValues, config) => { + wrapper = shallowMount(component, { + stubs: { + GlSprintf, + SettingsBlock, + }, + provide, + ...config, + }); + }; + + const mountComponentWithApollo = ({ provide = defaultProvidedValues, resolver } = {}) => { + const requestHandlers = [[packagesCleanupPolicyQuery, resolver]]; + + fakeApollo = createMockApollo(requestHandlers); + mountComponent(provide, { + apolloProvider: fakeApollo, + }); + }; + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + it('renders the setting form', async () => { + mountComponentWithApollo({ + resolver: jest.fn().mockResolvedValue(packagesCleanupPolicyPayload()), + }); + await waitForPromises(); + + expect(findFormComponent().exists()).toBe(true); + expect(findFormComponent().props('value')).toEqual(packagesCleanupPolicyData); + expect(findSettingsBlock().exists()).toBe(true); + }); + + describe('fetchSettingsError', () => { + beforeEach(async () => { + mountComponentWithApollo({ + resolver: jest.fn().mockRejectedValue(new Error('GraphQL error')), + }); + await waitForPromises(); + }); + + it('the form is hidden', () => { + expect(findFormComponent().exists()).toBe(false); + }); + + it('shows an alert', () => { + expect(findAlert().html()).toContain(FETCH_SETTINGS_ERROR_MESSAGE); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js index 337991dfae0..f576bc79eae 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js @@ -1,19 +1,41 @@ import { shallowMount } from '@vue/test-utils'; import component from '~/packages_and_registries/settings/project/components/registry_settings_app.vue'; import ContainerExpirationPolicy from '~/packages_and_registries/settings/project/components/container_expiration_policy.vue'; +import PackagesCleanupPolicy from '~/packages_and_registries/settings/project/components/packages_cleanup_policy.vue'; describe('Registry Settings app', () => { let wrapper; + const findContainerExpirationPolicy = () => wrapper.find(ContainerExpirationPolicy); + const findPackagesCleanupPolicy = () => wrapper.find(PackagesCleanupPolicy); afterEach(() => { wrapper.destroy(); wrapper = null; }); - it('renders container expiration policy component', () => { - wrapper = shallowMount(component); + const mountComponent = (provide) => { + wrapper = shallowMount(component, { + provide, + }); + }; - expect(findContainerExpirationPolicy().exists()).toBe(true); - }); + it.each` + showContainerRegistrySettings | showPackageRegistrySettings + ${true} | ${false} + ${true} | ${true} + ${false} | ${true} + ${false} | ${false} + `( + 'container expiration policy $showContainerRegistrySettings and package cleanup policy is $showPackageRegistrySettings', + ({ showContainerRegistrySettings, showPackageRegistrySettings }) => { + mountComponent({ + showContainerRegistrySettings, + showPackageRegistrySettings, + }); + + expect(findContainerExpirationPolicy().exists()).toBe(showContainerRegistrySettings); + expect(findPackagesCleanupPolicy().exists()).toBe(showPackageRegistrySettings); + }, + ); }); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js b/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js index 33406c98f4b..d4b6c66ddeb 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js @@ -40,3 +40,33 @@ export const expirationPolicyMutationPayload = ({ override, errors = [] } = {}) }, }, }); + +export const packagesCleanupPolicyData = { + keepNDuplicatedPackageFiles: 'ALL_PACKAGE_FILES', + nextRunAt: '2020-11-19T07:37:03.941Z', +}; + +export const packagesCleanupPolicyPayload = (override) => ({ + data: { + project: { + id: '1', + packagesCleanupPolicy: { + __typename: 'PackagesCleanupPolicy', + ...packagesCleanupPolicyData, + ...override, + }, + }, + }, +}); + +export const packagesCleanupPolicyMutationPayload = ({ override, errors = [] } = {}) => ({ + data: { + updatePackagesCleanupPolicy: { + packagesCleanupPolicy: { + ...packagesCleanupPolicyData, + ...override, + }, + errors, + }, + }, +}); diff --git a/spec/frontend/packages_and_registries/shared/components/settings_block_spec.js b/spec/frontend/packages_and_registries/shared/components/settings_block_spec.js new file mode 100644 index 00000000000..a4c1b989dac --- /dev/null +++ b/spec/frontend/packages_and_registries/shared/components/settings_block_spec.js @@ -0,0 +1,43 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue'; + +describe('SettingsBlock', () => { + let wrapper; + + const mountComponent = (propsData) => { + wrapper = shallowMountExtended(SettingsBlock, { + propsData, + slots: { + title: '<div data-testid="title-slot"></div>', + description: '<div data-testid="description-slot"></div>', + default: '<div data-testid="default-slot"></div>', + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findDefaultSlot = () => wrapper.findByTestId('default-slot'); + const findTitleSlot = () => wrapper.findByTestId('title-slot'); + const findDescriptionSlot = () => wrapper.findByTestId('description-slot'); + + it('has a default slot', () => { + mountComponent(); + + expect(findDefaultSlot().exists()).toBe(true); + }); + + it('has a title slot', () => { + mountComponent(); + + expect(findTitleSlot().exists()).toBe(true); + }); + + it('has a description slot', () => { + mountComponent(); + + expect(findDescriptionSlot().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/pages/dashboard/todos/index/todos_spec.js b/spec/frontend/pages/dashboard/todos/index/todos_spec.js index 3a9b59f291c..03aed7454e3 100644 --- a/spec/frontend/pages/dashboard/todos/index/todos_spec.js +++ b/spec/frontend/pages/dashboard/todos/index/todos_spec.js @@ -1,5 +1,4 @@ import MockAdapter from 'axios-mock-adapter'; -import $ from 'jquery'; import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import waitForPromises from 'helpers/wait_for_promises'; import '~/lib/utils/common_utils'; @@ -54,22 +53,28 @@ describe('Todos', () => { let metakeyEvent; beforeEach(() => { - metakeyEvent = $.Event('click', { keyCode: 91, ctrlKey: true }); + metakeyEvent = new MouseEvent('click', { ctrlKey: true }); windowOpenSpy = jest.spyOn(window, 'open').mockImplementation(() => {}); }); it('opens the todo url in another tab', () => { const todoLink = todoItem.dataset.url; - $('.todos-list .todo').trigger(metakeyEvent); + document.querySelectorAll('.todos-list .todo').forEach((el) => { + el.dispatchEvent(metakeyEvent); + }); expect(visitUrl).not.toHaveBeenCalled(); expect(windowOpenSpy).toHaveBeenCalledWith(todoLink, '_blank'); }); it('run native funcionality when avatar is clicked', () => { - $('.todos-list a').on('click', (e) => e.preventDefault()); - $('.todos-list img').trigger(metakeyEvent); + document.querySelectorAll('.todos-list a').forEach((el) => { + el.addEventListener('click', (e) => e.preventDefault()); + }); + document.querySelectorAll('.todos-list img').forEach((el) => { + el.dispatchEvent(metakeyEvent); + }); expect(visitUrl).not.toHaveBeenCalled(); expect(windowOpenSpy).not.toHaveBeenCalled(); @@ -88,7 +93,7 @@ describe('Todos', () => { .onDelete(path) .replyOnce(200, { count: TEST_COUNT_BIG, done_count: TEST_DONE_COUNT_BIG }); onToggleSpy = jest.fn(); - $(document).on('todo:toggle', onToggleSpy); + document.addEventListener('todo:toggle', onToggleSpy); // Act el.click(); @@ -98,7 +103,13 @@ describe('Todos', () => { }); it('dispatches todo:toggle', () => { - expect(onToggleSpy).toHaveBeenCalledWith(expect.anything(), TEST_COUNT_BIG); + expect(onToggleSpy).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + count: TEST_COUNT_BIG, + }, + }), + ); }); it('updates pending text', () => { diff --git a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js index efbfd83a071..2a0fde45384 100644 --- a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js +++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js @@ -400,10 +400,6 @@ describe('ForkForm component', () => { ); }; - beforeEach(() => { - setupComponent(); - }); - const selectedMockNamespaceIndex = 1; const namespaceId = MOCK_NAMESPACES_RESPONSE[selectedMockNamespaceIndex].id; @@ -425,10 +421,14 @@ describe('ForkForm component', () => { it('does not make POST request', async () => { jest.spyOn(axios, 'post'); + setupComponent(); + expect(axios.post).not.toHaveBeenCalled(); }); it('does not redirect the current page', async () => { + setupComponent(); + await submitForm(); expect(urlUtility.redirectTo).not.toHaveBeenCalled(); @@ -452,13 +452,10 @@ describe('ForkForm component', () => { }); describe('with valid form', () => { - beforeEach(() => { - fillForm(); - }); - it('make POST request with project param', async () => { jest.spyOn(axios, 'post'); + setupComponent(); await submitForm(); const { @@ -486,6 +483,7 @@ describe('ForkForm component', () => { const webUrl = `new/fork-project`; jest.spyOn(axios, 'post').mockResolvedValue({ data: { web_url: webUrl } }); + setupComponent(); await submitForm(); expect(urlUtility.redirectTo).toHaveBeenCalledWith(webUrl); @@ -496,6 +494,7 @@ describe('ForkForm component', () => { jest.spyOn(axios, 'post').mockRejectedValue(dummyError); + setupComponent(); await submitForm(); expect(urlUtility.redirectTo).not.toHaveBeenCalled(); diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap index 005b8968383..aab78c99190 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap +++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap @@ -85,8 +85,6 @@ exports[`Learn GitLab renders correctly 1`] = ` <div class="gl-mb-4" > - <!----> - <div class="flex align-items-center" > @@ -105,7 +103,8 @@ exports[`Learn GitLab renders correctly 1`] = ` </svg> Invite your colleagues - + + <!----> </span> <!----> @@ -114,8 +113,6 @@ exports[`Learn GitLab renders correctly 1`] = ` <div class="gl-mb-4" > - <!----> - <div class="flex align-items-center" > @@ -133,8 +130,9 @@ exports[`Learn GitLab renders correctly 1`] = ` /> </svg> - Create or import a repository - + Create a repository + + <!----> </span> <!----> @@ -143,23 +141,23 @@ exports[`Learn GitLab renders correctly 1`] = ` <div class="gl-mb-4" > - <!----> - <div class="flex align-items-center" > - <a - class="gl-link" - data-testid="uncompleted-learn-gitlab-link" - data-track-action="click_link" - data-track-label="Set up CI/CD" - href="http://example.com/" - target="_self" - > - - Set up CI/CD - - </a> + <div> + <a + class="gl-link" + data-testid="uncompleted-learn-gitlab-link" + data-track-action="click_link" + data-track-label="set_up_your_first_project_s_ci_cd" + href="http://example.com/" + target="_self" + > + Set up your first project's CI/CD + </a> + + <!----> + </div> <!----> </div> @@ -167,24 +165,24 @@ exports[`Learn GitLab renders correctly 1`] = ` <div class="gl-mb-4" > - <!----> - <div class="flex align-items-center" > - <a - class="gl-link" - data-testid="uncompleted-learn-gitlab-link" - data-track-action="click_link" - data-track-label="Start a free Ultimate trial" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - - Start a free Ultimate trial - - </a> + <div> + <a + class="gl-link" + data-testid="uncompleted-learn-gitlab-link" + data-track-action="click_link" + data-track-label="start_a_free_trial_of_gitlab_ultimate" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + Start a free trial of GitLab Ultimate + </a> + + <!----> + </div> <!----> </div> @@ -193,30 +191,30 @@ exports[`Learn GitLab renders correctly 1`] = ` class="gl-mb-4" > <div - class="gl-font-style-italic gl-text-gray-500" - data-testid="trial-only" - > - - Trial only - - </div> - - <div class="flex align-items-center" > - <a - class="gl-link" - data-testid="uncompleted-learn-gitlab-link" - data-track-action="click_link" - data-track-label="Add code owners" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - - Add code owners - - </a> + <div> + <a + class="gl-link" + data-testid="uncompleted-learn-gitlab-link" + data-track-action="click_link" + data-track-label="add_code_owners" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + Add code owners + </a> + + <span + class="gl-font-style-italic gl-text-gray-500" + data-testid="trial-only" + > + + - Included in trial + + </span> + </div> <!----> </div> @@ -225,30 +223,30 @@ exports[`Learn GitLab renders correctly 1`] = ` class="gl-mb-4" > <div - class="gl-font-style-italic gl-text-gray-500" - data-testid="trial-only" - > - - Trial only - - </div> - - <div class="flex align-items-center" > - <a - class="gl-link" - data-testid="uncompleted-learn-gitlab-link" - data-track-action="click_link" - data-track-label="Add merge request approval" - href="http://example.com/" - rel="noopener noreferrer" - target="_blank" - > - - Add merge request approval - - </a> + <div> + <a + class="gl-link" + data-testid="uncompleted-learn-gitlab-link" + data-track-action="click_link" + data-track-label="enable_require_merge_approvals" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + Enable require merge approvals + </a> + + <span + class="gl-font-style-italic gl-text-gray-500" + data-testid="trial-only" + > + + - Included in trial + + </span> + </div> <!----> </div> @@ -290,23 +288,23 @@ exports[`Learn GitLab renders correctly 1`] = ` <div class="gl-mb-4" > - <!----> - <div class="flex align-items-center" > - <a - class="gl-link" - data-testid="uncompleted-learn-gitlab-link" - data-track-action="click_link" - data-track-label="Create an issue" - href="http://example.com/" - target="_self" - > - - Create an issue - - </a> + <div> + <a + class="gl-link" + data-testid="uncompleted-learn-gitlab-link" + data-track-action="click_link" + data-track-label="create_an_issue" + href="http://example.com/" + target="_self" + > + Create an issue + </a> + + <!----> + </div> <!----> </div> @@ -314,23 +312,23 @@ exports[`Learn GitLab renders correctly 1`] = ` <div class="gl-mb-4" > - <!----> - <div class="flex align-items-center" > - <a - class="gl-link" - data-testid="uncompleted-learn-gitlab-link" - data-track-action="click_link" - data-track-label="Submit a merge request" - href="http://example.com/" - target="_self" - > - - Submit a merge request - - </a> + <div> + <a + class="gl-link" + data-testid="uncompleted-learn-gitlab-link" + data-track-action="click_link" + data-track-label="submit_a_merge_request_mr" + href="http://example.com/" + target="_self" + > + Submit a merge request (MR) + </a> + + <!----> + </div> <!----> </div> @@ -372,24 +370,24 @@ exports[`Learn GitLab renders correctly 1`] = ` <div class="gl-mb-4" > - <!----> - <div class="flex align-items-center" > - <a - class="gl-link" - data-testid="uncompleted-learn-gitlab-link" - data-track-action="click_link" - data-track-label="Run a Security scan using CI/CD" - href="https://docs.gitlab.com/ee/foobar/" - rel="noopener noreferrer" - target="_blank" - > - - Run a Security scan using CI/CD - - </a> + <div> + <a + class="gl-link" + data-testid="uncompleted-learn-gitlab-link" + data-track-action="click_link" + data-track-label="run_a_security_scan_using_ci_cd" + href="https://docs.gitlab.com/ee/foobar/" + rel="noopener noreferrer" + target="_blank" + > + Run a Security scan using CI/CD + </a> + + <!----> + </div> <!----> </div> diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_info_card_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_info_card_spec.js deleted file mode 100644 index ad4bc826a9d..00000000000 --- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_info_card_spec.js +++ /dev/null @@ -1,57 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import LearnGitlabInfoCard from '~/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue'; - -const defaultProps = { - title: 'Create Repository', - description: 'Some description', - actionLabel: 'Create Repository now', - url: 'https://example.com', - completed: false, - svg: 'https://example.com/illustration.svg', -}; - -describe('Learn GitLab Info Card', () => { - let wrapper; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - const createWrapper = (props = {}) => { - wrapper = shallowMount(LearnGitlabInfoCard, { - propsData: { ...defaultProps, ...props }, - }); - }; - - it('renders no icon when not completed', () => { - createWrapper({ completed: false }); - - expect(wrapper.find('[data-testid="completed-icon"]').exists()).toBe(false); - }); - - it('renders the completion icon when completed', () => { - createWrapper({ completed: true }); - - expect(wrapper.find('[data-testid="completed-icon"]').exists()).toBe(true); - }); - - it('renders no trial only when it is not required', () => { - createWrapper(); - - expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(false); - }); - - it('renders trial only when trial is required', () => { - createWrapper({ trialRequired: true }); - - expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(true); - }); - - it('renders completion icon when completed a trial-only feature', () => { - createWrapper({ trialRequired: true, completed: true }); - - expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(false); - expect(wrapper.find('[data-testid="completed-icon"]').exists()).toBe(true); - }); -}); diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js index d9aff37f703..897cbf5eaa4 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js +++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js @@ -119,7 +119,7 @@ describe('Learn GitLab Section Link', () => { findUncompletedLink().trigger('click'); expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_link', { - label: 'Run a Security scan using CI/CD', + label: 'run_a_security_scan_using_ci_cd', }); unmockTracking(); @@ -164,7 +164,7 @@ describe('Learn GitLab Section Link', () => { triggerEvent(openInviteMembesrModalLink().element); expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_link', { - label: 'Invite your colleagues', + label: 'invite_your_colleagues', property: 'Growth::Activation::Experiment::InviteForHelpContinuousOnboarding', }); @@ -203,7 +203,7 @@ describe('Learn GitLab Section Link', () => { videoTutorialLink().trigger('click'); expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_video_link', { - label: 'Add code owners', + label: 'add_code_owners', property: 'Growth::Conversion::Experiment::LearnGitLab', context: { data: { diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_trial_card_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_trial_card_spec.js new file mode 100644 index 00000000000..6ab57e31fed --- /dev/null +++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_trial_card_spec.js @@ -0,0 +1,12 @@ +import { shallowMount } from '@vue/test-utils'; +import IncludedInTrialIndicator from '~/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue'; + +describe('Learn GitLab Trial Card', () => { + it('renders correctly', () => { + const wrapper = shallowMount(IncludedInTrialIndicator); + + expect(wrapper.text()).toEqual('- Included in trial'); + + wrapper.destroy(); + }); +}); diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js index d5b4b3c22d8..99df5b58d90 100644 --- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js @@ -31,10 +31,10 @@ describe('Interval Pattern Input Component', () => { wrapper.findAll('input[type="radio"]').wrappers.find((x) => x.element.checked); const findIcon = () => wrapper.findComponent(GlIcon); const findSelectedRadioKey = () => findSelectedRadio()?.attributes('data-testid'); - const selectEveryDayRadio = () => findEveryDayRadio().trigger('click'); - const selectEveryWeekRadio = () => findEveryWeekRadio().trigger('click'); - const selectEveryMonthRadio = () => findEveryMonthRadio().trigger('click'); - const selectCustomRadio = () => findCustomRadio().trigger('click'); + const selectEveryDayRadio = () => findEveryDayRadio().setChecked(true); + const selectEveryWeekRadio = () => findEveryWeekRadio().setChecked(true); + const selectEveryMonthRadio = () => findEveryMonthRadio().setChecked(true); + const selectCustomRadio = () => findCustomRadio().setChecked(true); const createWrapper = (props = {}, data = {}) => { if (wrapper) { 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 46f83ac89e5..85660d09baa 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 @@ -51,6 +51,7 @@ const defaultProps = { requestCveAvailable: true, confirmationPhrase: 'my-fake-project', showVisibilityConfirmModal: false, + membersPagePath: '/my-fake-project/-/project_members', }; const FEATURE_ACCESS_LEVEL_ANONYMOUS = 30; @@ -59,7 +60,7 @@ describe('Settings Panel', () => { let wrapper; const mountComponent = ( - { currentSettings = {}, glFeatures = {}, ...customProps } = {}, + { currentSettings = {}, glFeatures = {}, stubs = {}, ...customProps } = {}, mountFn = shallowMount, ) => { const propsData = { @@ -76,6 +77,7 @@ describe('Settings Panel', () => { ...glFeatures, }, }, + stubs, }); }; @@ -176,7 +178,7 @@ describe('Settings Panel', () => { ); it('should set the visibility level description based upon the selected visibility level', () => { - wrapper = mountComponent(); + wrapper = mountComponent({ stubs: { GlSprintf } }); findProjectVisibilityLevelInput().setValue(visibilityOptions.INTERNAL); diff --git a/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js index 365bb878485..108f816fe01 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js @@ -7,8 +7,10 @@ import { renderGFM } from '~/pages/shared/wikis/render_gfm_facade'; import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; import waitForPromises from 'helpers/wait_for_promises'; +import { handleLocationHash } from '~/lib/utils/common_utils'; jest.mock('~/pages/shared/wikis/render_gfm_facade'); +jest.mock('~/lib/utils/common_utils'); describe('pages/shared/wikis/components/wiki_content', () => { const PATH = '/test'; @@ -76,6 +78,12 @@ describe('pages/shared/wikis/components/wiki_content', () => { expect(renderGFM).toHaveBeenCalledWith(wrapper.element); }); + + it('handles hash after render', async () => { + await nextTick(); + + expect(handleLocationHash).toHaveBeenCalled(); + }); }); describe('when loading content fails', () => { 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 d7f8dc3c98e..a5db10d106d 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js @@ -1,12 +1,14 @@ import { nextTick } from 'vue'; -import { GlAlert, GlButton } from '@gitlab/ui'; +import { GlAlert, GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { mockTracking } from 'helpers/tracking_helper'; +import { stubComponent } from 'helpers/stub_component'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import ContentEditor from '~/content_editor/components/content_editor.vue'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import WikiForm from '~/pages/shared/wikis/components/wiki_form.vue'; import { CONTENT_EDITOR_LOADED_ACTION, @@ -37,6 +39,7 @@ describe('WikiForm', () => { const findMarkdownHelpLink = () => wrapper.findByTestId('wiki-markdown-help-link'); const findContentEditor = () => wrapper.findComponent(ContentEditor); const findClassicEditor = () => wrapper.findComponent(MarkdownField); + const findLocalStorageSync = () => wrapper.find(LocalStorageSync); const setFormat = (value) => { const format = findFormat(); @@ -103,6 +106,9 @@ describe('WikiForm', () => { MarkdownField, GlAlert, GlButton, + LocalStorageSync: stubComponent(LocalStorageSync), + GlFormInput, + GlFormGroup, }, }), ); @@ -128,7 +134,7 @@ describe('WikiForm', () => { `( 'updates the commit message to $message when title is $title and persisted=$persisted', async ({ title, message, persisted }) => { - createWrapper({ persisted }); + createWrapper({ persisted, mountFn: mount }); await findTitle().setValue(title); @@ -137,7 +143,7 @@ describe('WikiForm', () => { ); it('sets the commit message to "Update My page" when the page first loads when persisted', async () => { - createWrapper({ persisted: true }); + createWrapper({ persisted: true, mountFn: mount }); await nextTick(); @@ -157,7 +163,7 @@ describe('WikiForm', () => { ${'asciidoc'} | ${false} | ${'hides'} ${'org'} | ${false} | ${'hides'} `('$action preview in the markdown field when format is $format', async ({ format, enabled }) => { - createWrapper(); + createWrapper({ mountFn: mount }); await setFormat(format); @@ -254,7 +260,7 @@ describe('WikiForm', () => { `( "when title='$title', content='$content', then the button is $buttonState'", async ({ title, content, disabledAttr }) => { - createWrapper(); + createWrapper({ mountFn: mount }); await findTitle().setValue(title); await findContent().setValue(content); @@ -291,7 +297,7 @@ describe('WikiForm', () => { describe('toggle editing mode control', () => { beforeEach(() => { - createWrapper(); + createWrapper({ mountFn: mount }); }); it.each` @@ -330,6 +336,19 @@ describe('WikiForm', () => { }); }); + describe('markdown editor type persistance', () => { + it('loads content editor by default if it is persisted in local storage', async () => { + expect(findClassicEditor().exists()).toBe(true); + expect(findContentEditor().exists()).toBe(false); + + // enable content editor + await findLocalStorageSync().vm.$emit('input', true); + + expect(findContentEditor().exists()).toBe(true); + expect(findClassicEditor().exists()).toBe(false); + }); + }); + describe('when content editor is active', () => { let mockContentEditor; @@ -374,7 +393,7 @@ describe('WikiForm', () => { }); describe('wiki content editor', () => { - describe('clicking "use new editor": editor fails to load', () => { + describe('clicking "Edit rich text": editor fails to load', () => { beforeEach(async () => { createWrapper({ mountFn: mount }); mock.onPost(/preview-markdown/).reply(400); @@ -401,7 +420,7 @@ describe('WikiForm', () => { }); }); - describe('clicking "use new editor": editor loads successfully', () => { + describe('clicking "Edit rich text": editor loads successfully', () => { beforeEach(async () => { createWrapper({ persisted: true, mountFn: mount }); diff --git a/spec/frontend/pdf/index_spec.js b/spec/frontend/pdf/index_spec.js index 2b0932493bb..98946412264 100644 --- a/spec/frontend/pdf/index_spec.js +++ b/spec/frontend/pdf/index_spec.js @@ -1,48 +1,33 @@ -import Vue from 'vue'; - +import { shallowMount } from '@vue/test-utils'; import { FIXTURES_PATH } from 'spec/test_constants'; import PDFLab from '~/pdf/index.vue'; -jest.mock('pdfjs-dist/webpack', () => { - return { default: jest.requireActual('pdfjs-dist/build/pdf') }; -}); - -const pdf = `${FIXTURES_PATH}/blob/pdf/test.pdf`; +describe('PDFLab component', () => { + let wrapper; -const Component = Vue.extend(PDFLab); + const mountComponent = ({ pdf }) => shallowMount(PDFLab, { propsData: { pdf } }); -describe('PDF component', () => { - let vm; + afterEach(() => { + wrapper.destroy(); + }); describe('without PDF data', () => { beforeEach(() => { - vm = new Component({ - propsData: { - pdf: '', - }, - }); - - vm.$mount(); + wrapper = mountComponent({ pdf: '' }); }); it('does not render', () => { - expect(vm.$el.tagName).toBeUndefined(); + expect(wrapper.isVisible()).toBe(false); }); }); describe('with PDF data', () => { beforeEach(() => { - vm = new Component({ - propsData: { - pdf, - }, - }); - - vm.$mount(); + wrapper = mountComponent({ pdf: `${FIXTURES_PATH}/blob/pdf/test.pdf` }); }); - it('renders pdf component', () => { - expect(vm.$el.tagName).toBeDefined(); + it('renders', () => { + expect(wrapper.isVisible()).toBe(true); }); }); }); diff --git a/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js b/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js index ae19ed9ab02..82ac390971d 100644 --- a/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js +++ b/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js @@ -152,4 +152,26 @@ describe('CI Lint Results', () => { expect(findAfterScripts()).toHaveLength(filterEmptyScripts('afterScript').length); }); }); + + describe('Hide Alert', () => { + it('hides alert on success if hide-alert prop is true', async () => { + await createComponent({ dryRun: true, hideAlert: true }, mount); + + expect(findStatus().exists()).toBe(false); + }); + + it('hides alert on error if hide-alert prop is true', async () => { + await createComponent( + { + hideAlert: true, + isValid: false, + errors: mockErrors, + warnings: mockWarnings, + }, + mount, + ); + + expect(findStatus().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js index 3ecf6472544..87a7f07f7d4 100644 --- a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js +++ b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js @@ -1,6 +1,8 @@ -import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; +import { GlAlert, GlBadge, GlLoadingIcon, GlTabs } from '@gitlab/ui'; +import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; import Vue, { nextTick } from 'vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue'; import CiLint from '~/pipeline_editor/components/lint/ci_lint.vue'; @@ -15,9 +17,21 @@ import { EDITOR_APP_STATUS_INVALID, EDITOR_APP_STATUS_VALID, TAB_QUERY_PARAM, + VALIDATE_TAB, + VALIDATE_TAB_BADGE_DISMISSED_KEY, } from '~/pipeline_editor/constants'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; -import { mockLintResponse, mockLintResponseWithoutMerged, mockCiYml } from '../mock_data'; +import getBlobContent from '~/pipeline_editor/graphql/queries/blob_content.query.graphql'; +import { + mockBlobContentQueryResponse, + mockCiLintPath, + mockCiYml, + mockLintResponse, + mockLintResponseWithoutMerged, +} from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); Vue.config.ignoredElements = ['gl-emoji']; @@ -33,11 +47,13 @@ describe('Pipeline editor tabs component', () => { provide = {}, appStatus = EDITOR_APP_STATUS_VALID, mountFn = shallowMount, + options = {}, } = {}) => { wrapper = mountFn(PipelineEditorTabs, { propsData: { ciConfigData: mockLintResponse, ciFileContent: mockCiYml, + currentTab: CREATE_TAB, isNewCiConfigFile: true, showDrawer: false, ...props, @@ -47,12 +63,34 @@ describe('Pipeline editor tabs component', () => { appStatus, }; }, - provide: { ...provide }, + provide: { + ciLintPath: mockCiLintPath, + ...provide, + }, stubs: { TextEditor: MockTextEditor, EditorTab, }, listeners, + ...options, + }); + }; + + let mockBlobContentData; + let mockApollo; + + const createComponentWithApollo = ({ props, provide = {}, mountFn = shallowMount } = {}) => { + const handlers = [[getBlobContent, mockBlobContentData]]; + mockApollo = createMockApollo(handlers); + + createComponent({ + props, + provide, + mountFn, + options: { + localVue, + apolloProvider: mockApollo, + }, }); }; @@ -63,6 +101,7 @@ describe('Pipeline editor tabs component', () => { const findVisualizationTab = () => wrapper.find('[data-testid="visualization-tab"]'); const findAlert = () => wrapper.findComponent(GlAlert); + const findBadge = () => wrapper.findComponent(GlBadge); const findCiLint = () => wrapper.findComponent(CiLint); const findCiValidate = () => wrapper.findComponent(CiValidate); const findGlTabs = () => wrapper.findComponent(GlTabs); @@ -72,6 +111,10 @@ describe('Pipeline editor tabs component', () => { const findMergedPreview = () => wrapper.findComponent(CiConfigMergedPreview); const findWalkthroughPopover = () => wrapper.findComponent(WalkthroughPopover); + beforeEach(() => { + mockBlobContentData = jest.fn(); + }); + afterEach(() => { wrapper.destroy(); }); @@ -114,37 +157,73 @@ describe('Pipeline editor tabs component', () => { describe('validate tab', () => { describe('with simulatePipeline feature flag ON', () => { - describe('while loading', () => { + describe('after loading', () => { beforeEach(() => { createComponent({ - appStatus: EDITOR_APP_STATUS_LOADING, - provide: { - glFeatures: { - simulatePipeline: true, - }, - }, + provide: { glFeatures: { simulatePipeline: true } }, }); }); - it('displays a loading icon if the lint query is loading', () => { - expect(findLoadingIcon().exists()).toBe(true); - }); - - it('does not display the validate component', () => { - expect(findCiValidate().exists()).toBe(false); + it('displays the tab and the validate component', () => { + expect(findValidateTab().exists()).toBe(true); + expect(findCiValidate().exists()).toBe(true); }); }); - describe('after loading', () => { - beforeEach(() => { - createComponent({ - provide: { glFeatures: { simulatePipeline: true } }, + describe('NEW badge', () => { + describe('default', () => { + beforeEach(() => { + mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); + createComponentWithApollo({ + mountFn: mount, + props: { + currentTab: VALIDATE_TAB, + }, + provide: { + glFeatures: { simulatePipeline: true }, + ciConfigPath: '/path/to/ci-config', + currentBranch: 'main', + projectFullPath: '/path/to/project', + simulatePipelineHelpPagePath: 'path/to/help/page', + validateTabIllustrationPath: 'path/to/svg', + }, + }); + }); + + it('renders badge by default', () => { + expect(findBadge().exists()).toBe(true); + expect(findBadge().text()).toBe(wrapper.vm.$options.i18n.new); + }); + + it('hides badge when moving away from the validate tab', async () => { + expect(findBadge().exists()).toBe(true); + + await findEditorTab().vm.$emit('click'); + + expect(findBadge().exists()).toBe(false); }); }); - it('displays the tab and the validate component', () => { - expect(findValidateTab().exists()).toBe(true); - expect(findCiValidate().exists()).toBe(true); + describe('if badge has been dismissed before', () => { + beforeEach(() => { + localStorage.setItem(VALIDATE_TAB_BADGE_DISMISSED_KEY, 'true'); + mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); + createComponentWithApollo({ + mountFn: mount, + provide: { + glFeatures: { simulatePipeline: true }, + ciConfigPath: '/path/to/ci-config', + currentBranch: 'main', + projectFullPath: '/path/to/project', + simulatePipelineHelpPagePath: 'path/to/help/page', + validateTabIllustrationPath: 'path/to/svg', + }, + }); + }); + + it('does not render badge if it has been dismissed before', () => { + expect(findBadge().exists()).toBe(false); + }); }); }); }); @@ -181,7 +260,6 @@ describe('Pipeline editor tabs component', () => { expect(findCiLint().exists()).toBe(false); }); }); - describe('after loading', () => { beforeEach(() => { createComponent(); diff --git a/spec/frontend/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js b/spec/frontend/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js new file mode 100644 index 00000000000..97f785a71bc --- /dev/null +++ b/spec/frontend/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js @@ -0,0 +1,43 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ValidatePopover from '~/pipeline_editor/components/popovers/validate_pipeline_popover.vue'; +import { VALIDATE_TAB_FEEDBACK_URL } from '~/pipeline_editor/constants'; +import { mockSimulatePipelineHelpPagePath } from '../../mock_data'; + +describe('ValidatePopover component', () => { + let wrapper; + + const createComponent = ({ stubs } = {}) => { + wrapper = shallowMountExtended(ValidatePopover, { + provide: { + simulatePipelineHelpPagePath: mockSimulatePipelineHelpPagePath, + }, + stubs, + }); + }; + + const findHelpLink = () => wrapper.findByTestId('help-link'); + const findFeedbackLink = () => wrapper.findByTestId('feedback-link'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + beforeEach(async () => { + createComponent({ + stubs: { GlLink, GlSprintf }, + }); + }); + + it('renders help link', () => { + expect(findHelpLink().exists()).toBe(true); + expect(findHelpLink().attributes('href')).toBe(mockSimulatePipelineHelpPagePath); + }); + + it('renders feedback link', () => { + expect(findFeedbackLink().exists()).toBe(true); + expect(findFeedbackLink().attributes('href')).toBe(VALIDATE_TAB_FEEDBACK_URL); + }); + }); +}); 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 6206a0f6aed..3a40ce32a24 100644 --- a/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js +++ b/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlTabs } from '@gitlab/ui'; +import { GlAlert, GlBadge, GlTabs } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue'; @@ -30,10 +30,10 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => { }, template: ` <gl-tabs> - <editor-tab :title-link-attributes="{ 'data-testid': 'tab1-btn' }" :lazy="true"> + <editor-tab title="Tab 1" :title-link-attributes="{ 'data-testid': 'tab1-btn' }" :lazy="true"> <mock-child content="${mockContent1}"/> </editor-tab> - <editor-tab :title-link-attributes="{ 'data-testid': 'tab2-btn' }" :lazy="true"> + <editor-tab title="Tab 2" :title-link-attributes="{ 'data-testid': 'tab2-btn' }" :lazy="true" badge-title="NEW"> <mock-child content="${mockContent2}"/> </editor-tab> </gl-tabs> @@ -46,7 +46,10 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => { const createWrapper = ({ props } = {}) => { wrapper = mount(EditorTab, { - propsData: props, + propsData: { + title: 'Tab 1', + ...props, + }, slots: { default: MockSourceEditor, }, @@ -55,6 +58,7 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => { const findSlotComponent = () => wrapper.findComponent(MockSourceEditor); const findAlert = () => wrapper.findComponent(GlAlert); + const findBadges = () => wrapper.findAll(GlBadge); beforeEach(() => { mockChildMounted = jest.fn(); @@ -182,4 +186,15 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => { expect(mockChildMounted).toHaveBeenNthCalledWith(2, mockContent2); }); }); + + describe('valid state', () => { + beforeEach(() => { + createMockedWrapper(); + }); + + it('renders correct number of badges', async () => { + expect(findBadges()).toHaveLength(1); + expect(findBadges().at(0).text()).toBe('NEW'); + }); + }); }); diff --git a/spec/frontend/pipeline_editor/components/validate/ci_validate_spec.js b/spec/frontend/pipeline_editor/components/validate/ci_validate_spec.js index 25972317593..f5f01b675b2 100644 --- a/spec/frontend/pipeline_editor/components/validate/ci_validate_spec.js +++ b/spec/frontend/pipeline_editor/components/validate/ci_validate_spec.js @@ -1,40 +1,279 @@ -import { GlButton, GlDropdown } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlAlert, GlDropdown, GlIcon, GlLoadingIcon, GlPopover } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue'; import CiValidate, { i18n } from '~/pipeline_editor/components/validate/ci_validate.vue'; +import ValidatePipelinePopover from '~/pipeline_editor/components/popovers/validate_pipeline_popover.vue'; +import getBlobContent from '~/pipeline_editor/graphql/queries/blob_content.query.graphql'; +import lintCIMutation from '~/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql'; +import { + mockBlobContentQueryResponse, + mockCiLintPath, + mockCiYml, + mockSimulatePipelineHelpPagePath, +} from '../../mock_data'; +import { mockLintDataError, mockLintDataValid } from '../../../ci_lint/mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); describe('Pipeline Editor Validate Tab', () => { let wrapper; + let mockApollo; + let mockBlobContentData; - const createComponent = ({ stubs } = {}) => { - wrapper = shallowMount(CiValidate, { + const createComponent = ({ + props, + stubs, + options, + isBlobLoading = false, + isSimulationLoading = false, + } = {}) => { + wrapper = shallowMountExtended(CiValidate, { + propsData: { + ciFileContent: mockCiYml, + ...props, + }, provide: { + ciConfigPath: '/path/to/ci-config', + ciLintPath: mockCiLintPath, + currentBranch: 'main', + projectFullPath: '/path/to/project', validateTabIllustrationPath: '/path/to/img', + simulatePipelineHelpPagePath: mockSimulatePipelineHelpPagePath, + }, + stubs, + mocks: { + $apollo: { + queries: { + initialBlobContent: { + loading: isBlobLoading, + }, + }, + mutations: { + lintCiMutation: { + loading: isSimulationLoading, + }, + }, + }, }, + ...options, + }); + }; + + const createComponentWithApollo = ({ props, stubs } = {}) => { + const handlers = [[getBlobContent, mockBlobContentData]]; + mockApollo = createMockApollo(handlers); + + createComponent({ + props, stubs, + options: { + localVue, + apolloProvider: mockApollo, + mocks: {}, + }, }); }; - const findCta = () => wrapper.findComponent(GlButton); + const findAlert = () => wrapper.findComponent(GlAlert); + const findCancelBtn = () => wrapper.findByTestId('cancel-simulation'); + const findContentChangeStatus = () => wrapper.findByTestId('content-status'); + const findCta = () => wrapper.findByTestId('simulate-pipeline-button'); + const findDisabledCtaTooltip = () => wrapper.findByTestId('cta-tooltip'); + const findHelpIcon = () => wrapper.findComponent(GlIcon); + const findIllustration = () => wrapper.findByRole('img'); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findPipelineSource = () => wrapper.findComponent(GlDropdown); + const findPopover = () => wrapper.findComponent(GlPopover); + const findCiLintResults = () => wrapper.findComponent(CiLintResults); + const findResultsCta = () => wrapper.findByTestId('resimulate-pipeline-button'); + + beforeEach(() => { + mockBlobContentData = jest.fn(); + }); afterEach(() => { wrapper.destroy(); }); - describe('template', () => { + describe('while initial CI content is loading', () => { beforeEach(() => { - createComponent(); + createComponent({ isBlobLoading: true }); + }); + + it('renders disabled CTA with tooltip', () => { + expect(findCta().props('disabled')).toBe(true); + expect(findDisabledCtaTooltip().exists()).toBe(true); + }); + }); + + describe('after initial CI content is loaded', () => { + beforeEach(async () => { + mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); + await createComponentWithApollo({ stubs: { GlPopover, ValidatePipelinePopover } }); }); it('renders disabled pipeline source dropdown', () => { expect(findPipelineSource().exists()).toBe(true); expect(findPipelineSource().attributes('text')).toBe(i18n.pipelineSourceDefault); - expect(findPipelineSource().attributes('disabled')).toBe('true'); + expect(findPipelineSource().props('disabled')).toBe(true); }); - it('renders CTA', () => { + it('renders enabled CTA without tooltip', () => { expect(findCta().exists()).toBe(true); - expect(findCta().text()).toBe(i18n.cta); + expect(findCta().props('disabled')).toBe(false); + expect(findDisabledCtaTooltip().exists()).toBe(false); + }); + + it('popover is set to render when hovering over help icon', () => { + expect(findPopover().props('target')).toBe(findHelpIcon().attributes('id')); + expect(findPopover().props('triggers')).toBe('hover focus'); + }); + }); + + describe('simulating the pipeline', () => { + beforeEach(async () => { + mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); + await createComponentWithApollo(); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid); + }); + + it('renders loading state while simulation is ongoing', async () => { + findCta().vm.$emit('click'); + await nextTick(); + + expect(findLoadingIcon().exists()).toBe(true); + expect(findCancelBtn().exists()).toBe(true); + expect(findCta().props('loading')).toBe(true); + }); + + it('calls mutation with the correct input', async () => { + await findCta().vm.$emit('click'); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: lintCIMutation, + variables: { + dry_run: true, + content: mockCiYml, + endpoint: mockCiLintPath, + }, + }); + }); + + describe('when results are successful', () => { + beforeEach(async () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid); + await findCta().vm.$emit('click'); + }); + + it('renders success alert', () => { + expect(findAlert().exists()).toBe(true); + expect(findAlert().attributes('variant')).toBe('success'); + expect(findAlert().attributes('title')).toBe(i18n.successAlertTitle); + }); + + it('does not render content change status or CTA for results page', () => { + expect(findContentChangeStatus().exists()).toBe(false); + expect(findResultsCta().exists()).toBe(false); + }); + + it('renders CI lint results with correct props', () => { + expect(findCiLintResults().exists()).toBe(true); + expect(findCiLintResults().props()).toMatchObject({ + dryRun: true, + hideAlert: true, + isValid: true, + jobs: mockLintDataValid.data.lintCI.jobs, + }); + }); + }); + + describe('when results have errors', () => { + beforeEach(async () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataError); + await findCta().vm.$emit('click'); + }); + + it('renders error alert', () => { + expect(findAlert().exists()).toBe(true); + expect(findAlert().attributes('variant')).toBe('danger'); + expect(findAlert().attributes('title')).toBe(i18n.errorAlertTitle); + }); + + it('renders CI lint results with correct props', () => { + expect(findCiLintResults().exists()).toBe(true); + expect(findCiLintResults().props()).toMatchObject({ + dryRun: true, + hideAlert: true, + isValid: false, + errors: mockLintDataError.data.lintCI.errors, + warnings: mockLintDataError.data.lintCI.warnings, + }); + }); + }); + }); + + describe('when CI content has changed after a simulation', () => { + beforeEach(async () => { + mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); + await createComponentWithApollo(); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid); + await findCta().vm.$emit('click'); + }); + + it('renders content change status', async () => { + await wrapper.setProps({ ciFileContent: 'new yaml content' }); + + expect(findContentChangeStatus().exists()).toBe(true); + expect(findResultsCta().exists()).toBe(true); + }); + + it('calls mutation with new content', async () => { + await wrapper.setProps({ ciFileContent: 'new yaml content' }); + await findResultsCta().vm.$emit('click'); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(2); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: lintCIMutation, + variables: { + dry_run: true, + content: 'new yaml content', + endpoint: mockCiLintPath, + }, + }); + }); + }); + + describe('canceling a simulation', () => { + beforeEach(async () => { + mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); + await createComponentWithApollo(); + }); + + it('returns to init state', async () => { + // init state + expect(findIllustration().exists()).toBe(true); + expect(findCiLintResults().exists()).toBe(false); + + // mutations should have successful results + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid); + findCta().vm.$emit('click'); + await nextTick(); + + // cancel before simulation succeeds + expect(findCancelBtn().exists()).toBe(true); + await findCancelBtn().vm.$emit('click'); + + // should still render init state + expect(findIllustration().exists()).toBe(true); + expect(findCiLintResults().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index 560b2820fae..2ea580b7b53 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -7,11 +7,13 @@ export const mockProjectFullPath = `${mockProjectNamespace}/${mockProjectPath}`; export const mockDefaultBranch = 'main'; export const mockNewBranch = 'new-branch'; export const mockNewMergeRequestPath = '/-/merge_requests/new'; +export const mockCiLintPath = '/-/ci/lint'; export const mockCommitSha = 'aabbccdd'; export const mockCommitNextSha = 'eeffgghh'; export const mockIncludesHelpPagePath = '/-/includes/help'; export const mockLintHelpPagePath = '/-/lint-help'; export const mockLintUnavailableHelpPagePath = '/-/pipeline-editor/troubleshoot'; +export const mockSimulatePipelineHelpPagePath = '/-/simulate-pipeline-help'; export const mockYmlHelpPagePath = '/-/yml-help'; export const mockCommitMessage = 'My commit message'; diff --git a/spec/frontend/pipeline_wizard/components/widgets/checklist_spec.js b/spec/frontend/pipeline_wizard/components/widgets/checklist_spec.js new file mode 100644 index 00000000000..43719595c5c --- /dev/null +++ b/spec/frontend/pipeline_wizard/components/widgets/checklist_spec.js @@ -0,0 +1,110 @@ +import { GlFormCheckbox, GlFormCheckboxGroup } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ChecklistWidget from '~/pipeline_wizard/components/widgets/checklist.vue'; + +describe('Pipeline Wizard - Checklist Widget', () => { + let wrapper; + const props = { + title: 'Foobar', + items: [ + 'foo bar baz', // simple, text-only content + { + text: 'abc', + help: 'def', + }, + ], + }; + + const getLastUpdateValidEvent = () => { + const eventArray = wrapper.emitted('update:valid'); + return eventArray[eventArray.length - 1]; + }; + const findItem = (atIndex = 0) => wrapper.findAllComponents(GlFormCheckbox).at(atIndex); + const getGlFormCheckboxGroup = () => wrapper.getComponent(GlFormCheckboxGroup); + + // The item.ids *can* be passed inside props.items, but are usually + // autogenerated by lodash.uniqueId() inside the component. So to + // get the actual value that the component expects to be emitted in + // GlFormCheckboxGroup's `v-model`, we need to obtain the value that is + // actually passed to GlFormCheckbox. + const getAllItemIds = () => props.items.map((_, i) => findItem(i).attributes().value); + + const createComponent = (mountFn = shallowMountExtended) => { + wrapper = mountFn(ChecklistWidget, { + propsData: { + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('creates the component', () => { + createComponent(); + expect(wrapper.exists()).toBe(true); + }); + + it('displays the item', () => { + createComponent(); + expect(findItem().exists()).toBe(true); + }); + + it("displays the item's text", () => { + createComponent(); + expect(findItem().text()).toBe(props.items[0]); + }); + + it('displays an item with a help text', () => { + createComponent(); + const { text, help } = props.items[1]; + + const itemWrapper = findItem(1); + const itemText = itemWrapper.text(); + // Unfortunately there is no wrapper.slots() accessor in vue_test_utils. + // To make sure the help text is being passed to the correct slot, we need to + // access the slot internally. + // This selector accesses the text of the first slot named "help" in itemWrapper + const helpText = itemWrapper.vm.$slots?.help[0]?.text?.trim(); + + expect(itemText).toBe(text); + expect(helpText).toBe(help); + }); + + it("emits a 'update:valid' event after all boxes have been checked", async () => { + createComponent(); + // initially, `valid` should be false + expect(wrapper.emitted('update:valid')).toEqual([[false]]); + const values = getAllItemIds(); + // this mocks checking all the boxes + getGlFormCheckboxGroup().vm.$emit('input', values); + + await nextTick(); + + expect(wrapper.emitted('update:valid')).toEqual([[false], [true]]); + }); + + it('emits a invalid event after a box has been unchecked', async () => { + createComponent(); + // initially, `valid` should be false + expect(wrapper.emitted('update:valid')).toEqual([[false]]); + + // checking all the boxes first + const values = getAllItemIds(); + getGlFormCheckboxGroup().vm.$emit('input', values); + await nextTick(); + + // ensure the test later doesn't just pass because it doesn't emit + // `true` to begin with + expect(getLastUpdateValidEvent()).toEqual([true]); + + // Now we're unchecking the last box. + values.pop(); + getGlFormCheckboxGroup().vm.$emit('input', values); + await nextTick(); + + expect(getLastUpdateValidEvent()).toEqual([false]); + }); +}); diff --git a/spec/frontend/pipeline_wizard/pipeline_wizard_spec.js b/spec/frontend/pipeline_wizard/pipeline_wizard_spec.js index dd0304518a3..3f689ffdbc8 100644 --- a/spec/frontend/pipeline_wizard/pipeline_wizard_spec.js +++ b/spec/frontend/pipeline_wizard/pipeline_wizard_spec.js @@ -99,4 +99,12 @@ describe('PipelineWizard', () => { parseDocument(template).get('description').toString(), ); }); + + it('bubbles the done event upwards', () => { + createComponent(); + + wrapper.findComponent(PipelineWizardWrapper).vm.$emit('done'); + + expect(wrapper.emitted().done.length).toBe(1); + }); }); diff --git a/spec/frontend/pipeline_wizard/templates/pages_spec.js b/spec/frontend/pipeline_wizard/templates/pages_spec.js new file mode 100644 index 00000000000..f89e8f05475 --- /dev/null +++ b/spec/frontend/pipeline_wizard/templates/pages_spec.js @@ -0,0 +1,89 @@ +import { Document, parseDocument } from 'yaml'; +import PagesWizardTemplate from '~/pipeline_wizard/templates/pages.yml'; +import { merge } from '~/lib/utils/yaml'; + +const VAR_BUILD_IMAGE = '$BUILD_IMAGE'; +const VAR_INSTALLATION_STEPS = '$INSTALLATION_STEPS'; +const VAR_BUILD_STEPS = '$BUILD_STEPS'; + +const getYaml = () => parseDocument(PagesWizardTemplate); +const getFinalTemplate = () => { + const merged = new Document(); + const yaml = getYaml(); + yaml.toJS().steps.forEach((_, i) => { + merge(merged, yaml.getIn(['steps', i, 'template'])); + }); + return merged; +}; + +describe('Pages Template', () => { + it('is valid yaml', () => { + // Testing equality to an empty array (as opposed to just comparing + // errors.length) will cause jest to print the underlying error + expect(getYaml().errors).toEqual([]); + }); + + it('includes all `target`s in the respective `template`', () => { + const yaml = getYaml(); + const actual = yaml.toJS().steps.map((x, i) => ({ + inputs: x.inputs, + template: yaml.getIn(['steps', i, 'template']).toString(), + })); + + expect(actual).toEqual([ + { + inputs: [ + expect.objectContaining({ + label: 'Select your build image', + target: VAR_BUILD_IMAGE, + }), + expect.objectContaining({ + widget: 'checklist', + title: 'Before we begin, please check:', + }), + ], + template: expect.stringContaining(VAR_BUILD_IMAGE), + }, + { + inputs: [ + expect.objectContaining({ + label: 'Installation Steps', + target: VAR_INSTALLATION_STEPS, + }), + ], + template: expect.stringContaining(VAR_INSTALLATION_STEPS), + }, + { + inputs: [ + expect.objectContaining({ + label: 'Build Steps', + target: VAR_BUILD_STEPS, + }), + ], + template: expect.stringContaining(VAR_BUILD_STEPS), + }, + ]); + }); + + it('addresses all relevant instructions for a pages pipeline', () => { + const fullTemplate = getFinalTemplate(); + + expect(fullTemplate.toString()).toEqual( + `# The Docker image that will be used to build your app +image: ${VAR_BUILD_IMAGE} +# Functions that should be executed before the build script is run +before_script: ${VAR_INSTALLATION_STEPS} +pages: + script: ${VAR_BUILD_STEPS} + artifacts: + paths: + # The folder that contains the files to be exposed at the Page URL + - public + rules: + # This ensures that only pushes to the default branch will trigger + # a pages deploy + - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH +`, + ); + }); +}); diff --git a/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js b/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js index 3b5632a8a4e..bfbb5f934b9 100644 --- a/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js +++ b/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js @@ -49,15 +49,15 @@ describe('Failed Jobs App', () => { }); describe('loading spinner', () => { - beforeEach(() => { + it('displays loading spinner when fetching failed jobs', () => { createComponent(resolverSpy); - }); - it('displays loading spinner when fetching failed jobs', () => { expect(findLoadingSpinner().exists()).toBe(true); }); it('hides loading spinner after the failed jobs have been fetched', async () => { + createComponent(resolverSpy); + await waitForPromises(); expect(findLoadingSpinner().exists()).toBe(false); diff --git a/spec/frontend/pipelines/components/jobs/jobs_app_spec.js b/spec/frontend/pipelines/components/jobs/jobs_app_spec.js index 81e19a6c221..89b6f764b2f 100644 --- a/spec/frontend/pipelines/components/jobs/jobs_app_spec.js +++ b/spec/frontend/pipelines/components/jobs/jobs_app_spec.js @@ -50,20 +50,23 @@ describe('Jobs app', () => { }); describe('loading spinner', () => { - beforeEach(async () => { + const setup = async () => { createComponent(resolverSpy); await waitForPromises(); triggerInfiniteScroll(); - }); + }; + + it('displays loading spinner when fetching more jobs', async () => { + await setup(); - it('displays loading spinner when fetching more jobs', () => { expect(findLoadingSpinner().exists()).toBe(true); expect(findSkeletonLoader().exists()).toBe(false); }); it('hides loading spinner after jobs have been fetched', async () => { + await setup(); await waitForPromises(); expect(findLoadingSpinner().exists()).toBe(false); diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js index 49d64c6eac0..3eaf06e0656 100644 --- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js @@ -5,6 +5,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { stubPerformanceWebAPI } from 'helpers/performance'; import waitForPromises from 'helpers/wait_for_promises'; import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; import getUserCallouts from '~/graphql_shared/queries/get_user_callouts.query.graphql'; @@ -29,10 +30,16 @@ import * as Api from '~/pipelines/components/graph_shared/api'; import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; import * as parsingUtils from '~/pipelines/components/parsing_utils'; import getPipelineHeaderData from '~/pipelines/graphql/queries/get_pipeline_header_data.query.graphql'; +import getPerformanceInsights from '~/pipelines/graphql/queries/get_performance_insights.query.graphql'; import * as sentryUtils from '~/pipelines/utils'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { mockRunningPipelineHeaderData } from '../mock_data'; -import { mapCallouts, mockCalloutsResponse, mockPipelineResponse } from './mock_data'; +import { + mapCallouts, + mockCalloutsResponse, + mockPipelineResponse, + mockPerformanceInsightsResponse, +} from './mock_data'; const defaultProvide = { graphqlResourceEtag: 'frog/amphibirama/etag/', @@ -88,11 +95,15 @@ describe('Pipeline graph wrapper', () => { const callouts = mapCallouts(calloutsList); const getUserCalloutsHandler = jest.fn().mockResolvedValue(mockCalloutsResponse(callouts)); const getPipelineHeaderDataHandler = jest.fn().mockResolvedValue(mockRunningPipelineHeaderData); + const getPerformanceInsightsHandler = jest + .fn() + .mockResolvedValue(mockPerformanceInsightsResponse); const requestHandlers = [ [getPipelineHeaderData, getPipelineHeaderDataHandler], [getPipelineDetails, getPipelineDetailsHandler], [getUserCallouts, getUserCalloutsHandler], + [getPerformanceInsights, getPerformanceInsightsHandler], ]; const apolloProvider = createMockApollo(requestHandlers); @@ -502,9 +513,7 @@ describe('Pipeline graph wrapper', () => { describe('when no duration is obtained', () => { beforeEach(async () => { - jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => { - return []; - }); + stubPerformanceWebAPI(); createComponentWithApollo({ provide: { diff --git a/spec/frontend/pipelines/graph/graph_view_selector_spec.js b/spec/frontend/pipelines/graph/graph_view_selector_spec.js index f574f4dccc5..1397500bdc7 100644 --- a/spec/frontend/pipelines/graph/graph_view_selector_spec.js +++ b/spec/frontend/pipelines/graph/graph_view_selector_spec.js @@ -1,10 +1,19 @@ import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { LAYER_VIEW, STAGE_VIEW } from '~/pipelines/components/graph/constants'; import GraphViewSelector from '~/pipelines/components/graph/graph_view_selector.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import getPerformanceInsights from '~/pipelines/graphql/queries/get_performance_insights.query.graphql'; +import { mockPerformanceInsightsResponse } from './mock_data'; + +Vue.use(VueApollo); describe('the graph view selector component', () => { let wrapper; + let trackingSpy; const findDependenciesToggle = () => wrapper.find('[data-testid="show-links-toggle"]'); const findViewTypeSelector = () => wrapper.findComponent(GlButtonGroup); @@ -13,11 +22,13 @@ describe('the graph view selector component', () => { const findSwitcherLoader = () => wrapper.find('[data-testid="switcher-loading-state"]'); const findToggleLoader = () => findDependenciesToggle().find(GlLoadingIcon); const findHoverTip = () => wrapper.findComponent(GlAlert); + const findPipelineInsightsBtn = () => wrapper.find('[data-testid="pipeline-insights-btn"]'); const defaultProps = { showLinks: false, tipPreviouslyDismissed: false, type: STAGE_VIEW, + isPipelineComplete: true, }; const defaultData = { @@ -27,6 +38,14 @@ describe('the graph view selector component', () => { showLinksActive: false, }; + const getPerformanceInsightsHandler = jest + .fn() + .mockResolvedValue(mockPerformanceInsightsResponse); + + const requestHandlers = [[getPerformanceInsights, getPerformanceInsightsHandler]]; + + const apolloProvider = createMockApollo(requestHandlers); + const createComponent = ({ data = {}, mountFn = shallowMount, props = {} } = {}) => { wrapper = mountFn(GraphViewSelector, { propsData: { @@ -39,6 +58,7 @@ describe('the graph view selector component', () => { ...data, }; }, + apolloProvider, }); }; @@ -91,7 +111,6 @@ describe('the graph view selector component', () => { describe('events', () => { beforeEach(() => { - jest.useFakeTimers(); createComponent({ mountFn: mount, props: { @@ -203,5 +222,44 @@ describe('the graph view selector component', () => { expect(findHoverTip().exists()).toBe(false); }); }); + + describe('pipeline insights', () => { + it.each` + isPipelineComplete | shouldShow + ${true} | ${true} + ${false} | ${false} + `( + 'button should display $shouldShow if isPipelineComplete is $isPipelineComplete ', + ({ isPipelineComplete, shouldShow }) => { + createComponent({ + props: { + isPipelineComplete, + }, + }); + + expect(findPipelineInsightsBtn().exists()).toBe(shouldShow); + }, + ); + }); + + describe('tracking', () => { + beforeEach(() => { + createComponent(); + + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('tracks performance insights button click', () => { + findPipelineInsightsBtn().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_insights_button', { + label: 'performance_insights', + }); + }); + }); }); }); diff --git a/spec/frontend/pipelines/graph/job_group_dropdown_spec.js b/spec/frontend/pipelines/graph/job_group_dropdown_spec.js index 5d8e70bac31..d8afb33e148 100644 --- a/spec/frontend/pipelines/graph/job_group_dropdown_spec.js +++ b/spec/frontend/pipelines/graph/job_group_dropdown_spec.js @@ -79,7 +79,7 @@ describe('job group dropdown component', () => { it('renders button with group name and size', () => { expect(findButton().text()).toContain(group.name); - expect(findButton().text()).toContain(group.size); + expect(findButton().text()).toContain(group.size.toString()); }); it('renders dropdown with jobs', () => { diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js index fd97c2dbe77..cdeaa0db61d 100644 --- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js @@ -103,7 +103,7 @@ describe('Linked pipeline', () => { expect(findCardTooltip().text()).toContain(mockPipeline.project.name); expect(findCardTooltip().text()).toContain(mockPipeline.status.label); expect(findCardTooltip().text()).toContain(mockPipeline.sourceJob.name); - expect(findCardTooltip().text()).toContain(mockPipeline.id); + expect(findCardTooltip().text()).toContain(mockPipeline.id.toString()); }); it('should display multi-project label when pipeline project id is not the same as triggered pipeline project id', () => { diff --git a/spec/frontend/pipelines/graph/mock_data.js b/spec/frontend/pipelines/graph/mock_data.js index 6124d67af09..959bbcefc98 100644 --- a/spec/frontend/pipelines/graph/mock_data.js +++ b/spec/frontend/pipelines/graph/mock_data.js @@ -1038,3 +1038,245 @@ export const triggerJob = { action: null, }, }; + +export const mockPerformanceInsightsResponse = { + data: { + project: { + __typename: 'Project', + id: 'gid://gitlab/Project/20', + pipeline: { + __typename: 'Pipeline', + id: 'gid://gitlab/Ci::Pipeline/97', + jobs: { + __typename: 'CiJobConnection', + pageInfo: { + __typename: 'PageInfo', + hasNextPage: false, + }, + nodes: [ + { + __typename: 'CiJob', + id: 'gid://gitlab/Ci::Bridge/2502', + duration: null, + detailedStatus: { + __typename: 'DetailedStatus', + id: 'success-2502-2502', + detailsPath: '/root/lots-of-jobs-project/-/pipelines/98', + }, + name: 'trigger_job', + stage: { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/303', + name: 'deploy', + }, + startedAt: null, + queuedDuration: 424850.376278, + }, + { + __typename: 'CiJob', + id: 'gid://gitlab/Ci::Build/2501', + duration: 10, + detailedStatus: { + __typename: 'DetailedStatus', + id: 'success-2501-2501', + detailsPath: '/root/ci-project/-/jobs/2501', + }, + name: 'artifact_job', + stage: { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/303', + name: 'deploy', + }, + startedAt: '2022-07-01T16:31:41Z', + queuedDuration: 2.621553, + }, + { + __typename: 'CiJob', + id: 'gid://gitlab/Ci::Build/2500', + duration: 4, + detailedStatus: { + __typename: 'DetailedStatus', + id: 'success-2500-2500', + detailsPath: '/root/ci-project/-/jobs/2500', + }, + name: 'coverage_job', + stage: { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/302', + name: 'test', + }, + startedAt: '2022-07-01T16:31:33Z', + queuedDuration: 14.388869, + }, + { + __typename: 'CiJob', + id: 'gid://gitlab/Ci::Build/2499', + duration: 4, + detailedStatus: { + __typename: 'DetailedStatus', + id: 'success-2499-2499', + detailsPath: '/root/ci-project/-/jobs/2499', + }, + name: 'test_job_two', + stage: { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/302', + name: 'test', + }, + startedAt: '2022-07-01T16:31:28Z', + queuedDuration: 15.792664, + }, + { + __typename: 'CiJob', + id: 'gid://gitlab/Ci::Build/2498', + duration: 4, + detailedStatus: { + __typename: 'DetailedStatus', + id: 'success-2498-2498', + detailsPath: '/root/ci-project/-/jobs/2498', + }, + name: 'test_job_one', + stage: { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/302', + name: 'test', + }, + startedAt: '2022-07-01T16:31:17Z', + queuedDuration: 8.317072, + }, + { + __typename: 'CiJob', + id: 'gid://gitlab/Ci::Build/2497', + duration: 5, + detailedStatus: { + __typename: 'DetailedStatus', + id: 'failed-2497-2497', + detailsPath: '/root/ci-project/-/jobs/2497', + }, + name: 'allow_failure_test_job', + stage: { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/302', + name: 'test', + }, + startedAt: '2022-07-01T16:31:22Z', + queuedDuration: 3.547553, + }, + { + __typename: 'CiJob', + id: 'gid://gitlab/Ci::Build/2496', + duration: null, + detailedStatus: { + __typename: 'DetailedStatus', + id: 'manual-2496-2496', + detailsPath: '/root/ci-project/-/jobs/2496', + }, + name: 'test_manual_job', + stage: { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/302', + name: 'test', + }, + startedAt: null, + queuedDuration: null, + }, + { + __typename: 'CiJob', + id: 'gid://gitlab/Ci::Build/2495', + duration: 5, + detailedStatus: { + __typename: 'DetailedStatus', + id: 'success-2495-2495', + detailsPath: '/root/ci-project/-/jobs/2495', + }, + name: 'large_log_output', + stage: { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/301', + name: 'build', + }, + startedAt: '2022-07-01T16:31:11Z', + queuedDuration: 79.128625, + }, + { + __typename: 'CiJob', + id: 'gid://gitlab/Ci::Build/2494', + duration: 5, + detailedStatus: { + __typename: 'DetailedStatus', + id: 'success-2494-2494', + detailsPath: '/root/ci-project/-/jobs/2494', + }, + name: 'build_job', + stage: { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/301', + name: 'build', + }, + startedAt: '2022-07-01T16:31:05Z', + queuedDuration: 73.286895, + }, + { + __typename: 'CiJob', + id: 'gid://gitlab/Ci::Build/2493', + duration: 16, + detailedStatus: { + __typename: 'DetailedStatus', + id: 'success-2493-2493', + detailsPath: '/root/ci-project/-/jobs/2493', + }, + name: 'wait_job', + stage: { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/301', + name: 'build', + }, + startedAt: '2022-07-01T16:30:48Z', + queuedDuration: 56.258856, + }, + ], + }, + }, + }, + }, +}; + +export const mockPerformanceInsightsNextPageResponse = { + data: { + project: { + __typename: 'Project', + id: 'gid://gitlab/Project/20', + pipeline: { + __typename: 'Pipeline', + id: 'gid://gitlab/Ci::Pipeline/97', + jobs: { + __typename: 'CiJobConnection', + pageInfo: { + __typename: 'PageInfo', + hasNextPage: true, + }, + nodes: [ + { + __typename: 'CiJob', + id: 'gid://gitlab/Ci::Bridge/2502', + duration: null, + detailedStatus: { + __typename: 'DetailedStatus', + id: 'success-2502-2502', + detailsPath: '/root/lots-of-jobs-project/-/pipelines/98', + }, + name: 'trigger_job', + stage: { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/303', + name: 'deploy', + }, + startedAt: null, + queuedDuration: 424850.376278, + }, + ], + }, + }, + }, + }, +}; diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js index 5cc11adf696..859be8d342c 100644 --- a/spec/frontend/pipelines/header_component_spec.js +++ b/spec/frontend/pipelines/header_component_spec.js @@ -205,7 +205,7 @@ describe('Pipeline details header', () => { }); it('should call deletePipeline Mutation with pipeline id when modal is submitted', () => { - findDeleteModal().vm.$emit('ok'); + findDeleteModal().vm.$emit('primary'); expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ mutation: deletePipelineMutation, @@ -223,7 +223,7 @@ describe('Pipeline details header', () => { }, }); - findDeleteModal().vm.$emit('ok'); + findDeleteModal().vm.$emit('primary'); await waitForPromises(); expect(findAlert().text()).toBe(failureMessage); diff --git a/spec/frontend/pipelines/performance_insights_modal_spec.js b/spec/frontend/pipelines/performance_insights_modal_spec.js new file mode 100644 index 00000000000..b745eb1d78e --- /dev/null +++ b/spec/frontend/pipelines/performance_insights_modal_spec.js @@ -0,0 +1,122 @@ +import { GlAlert, GlLink, GlModal } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import waitForPromises from 'helpers/wait_for_promises'; +import PerformanceInsightsModal from '~/pipelines/components/performance_insights_modal.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { trimText } from 'helpers/text_helper'; +import getPerformanceInsights from '~/pipelines/graphql/queries/get_performance_insights.query.graphql'; +import { + mockPerformanceInsightsResponse, + mockPerformanceInsightsNextPageResponse, +} from './graph/mock_data'; + +Vue.use(VueApollo); + +describe('Performance insights modal', () => { + let wrapper; + + const findModal = () => wrapper.findComponent(GlModal); + const findAlert = () => wrapper.findComponent(GlAlert); + const findLink = () => wrapper.findComponent(GlLink); + const findQueuedCardData = () => wrapper.findByTestId('insights-queued-card-data'); + const findQueuedCardLink = () => wrapper.findByTestId('insights-queued-card-link'); + const findExecutedCardData = () => wrapper.findByTestId('insights-executed-card-data'); + const findExecutedCardLink = () => wrapper.findByTestId('insights-executed-card-link'); + const findSlowJobsStage = (index) => wrapper.findAllByTestId('insights-slow-job-stage').at(index); + const findSlowJobsLink = (index) => wrapper.findAllByTestId('insights-slow-job-link').at(index); + + const getPerformanceInsightsHandler = jest + .fn() + .mockResolvedValue(mockPerformanceInsightsResponse); + + const getPerformanceInsightsNextPageHandler = jest + .fn() + .mockResolvedValue(mockPerformanceInsightsNextPageResponse); + + const requestHandlers = [[getPerformanceInsights, getPerformanceInsightsHandler]]; + + const createComponent = (handlers = requestHandlers) => { + wrapper = shallowMountExtended(PerformanceInsightsModal, { + provide: { + pipelineIid: '1', + pipelineProjectPath: 'root/ci-project', + }, + apolloProvider: createMockApollo(handlers), + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('without next page', () => { + beforeEach(async () => { + createComponent(); + + await waitForPromises(); + }); + + it('displays modal', () => { + expect(findModal().exists()).toBe(true); + }); + + it('does not dispaly alert', () => { + expect(findAlert().exists()).toBe(false); + }); + + describe('queued duration card', () => { + it('displays card data', () => { + expect(trimText(findQueuedCardData().text())).toBe('4.9 days'); + }); + it('displays card link', () => { + expect(findQueuedCardLink().attributes('href')).toBe( + '/root/lots-of-jobs-project/-/pipelines/98', + ); + }); + }); + + describe('executed duration card', () => { + it('displays card data', () => { + expect(trimText(findExecutedCardData().text())).toBe('trigger_job'); + }); + it('displays card link', () => { + expect(findExecutedCardLink().attributes('href')).toBe( + '/root/lots-of-jobs-project/-/pipelines/98', + ); + }); + }); + + describe('slow jobs', () => { + it.each` + index | expectedStage | expectedName | expectedLink + ${0} | ${'build'} | ${'wait_job'} | ${'/root/ci-project/-/jobs/2493'} + ${1} | ${'deploy'} | ${'artifact_job'} | ${'/root/ci-project/-/jobs/2501'} + ${2} | ${'test'} | ${'allow_failure_test_job'} | ${'/root/ci-project/-/jobs/2497'} + ${3} | ${'build'} | ${'large_log_output'} | ${'/root/ci-project/-/jobs/2495'} + ${4} | ${'build'} | ${'build_job'} | ${'/root/ci-project/-/jobs/2494'} + `( + 'should display slow job correctly', + ({ index, expectedStage, expectedName, expectedLink }) => { + expect(findSlowJobsStage(index).text()).toBe(expectedStage); + expect(findSlowJobsLink(index).text()).toBe(expectedName); + expect(findSlowJobsLink(index).attributes('href')).toBe(expectedLink); + }, + ); + }); + }); + + describe('limit alert', () => { + it('displays limit alert when there is a next page', async () => { + createComponent([[getPerformanceInsights, getPerformanceInsightsNextPageHandler]]); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + expect(findLink().attributes('href')).toBe( + 'https://gitlab.com/gitlab-org/gitlab/-/issues/365902', + ); + }); + }); +}); diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index de9f394db43..ad6d650670a 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -665,7 +665,6 @@ describe('Pipelines', () => { it('stops polling & restarts polling', async () => { findStagesDropdownToggle().trigger('click'); - await waitForPromises(); expect(cancelMock).not.toHaveBeenCalled(); diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js index 6ab479a257c..f9b9da01a2b 100644 --- a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js @@ -49,7 +49,7 @@ describe('Mutations TestReports Store', () => { describe('set suite error', () => { it('should set the error message in state if provided', () => { - const message = 'Test report artifacts have expired'; + const message = 'Test report artifacts not found'; mutations[types.SET_SUITE_ERROR](mockState, { response: { data: { errors: message } }, diff --git a/spec/frontend/pipelines/test_reports/test_case_details_spec.js b/spec/frontend/pipelines/test_reports/test_case_details_spec.js index 29c07e5e9f8..f194864447c 100644 --- a/spec/frontend/pipelines/test_reports/test_case_details_spec.js +++ b/spec/frontend/pipelines/test_reports/test_case_details_spec.js @@ -3,6 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import TestCaseDetails from '~/pipelines/components/test_reports/test_case_details.vue'; import CodeBlock from '~/vue_shared/components/code_block.vue'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; describe('Test case details', () => { let wrapper; @@ -19,6 +20,7 @@ describe('Test case details', () => { system_output: 'Line 42 is broken', }; + const findCopyFileBtn = () => wrapper.findComponent(ModalCopyButton); const findModal = () => wrapper.findComponent(GlModal); const findName = () => wrapper.findByTestId('test-case-name'); const findFile = () => wrapper.findByTestId('test-case-file'); @@ -66,6 +68,10 @@ describe('Test case details', () => { expect(findFileLink().attributes('href')).toBe(defaultTestCase.filePath); }); + it('renders copy button for test case file', () => { + expect(findCopyFileBtn().attributes('text')).toBe(defaultTestCase.file); + }); + it('renders the test case duration', () => { expect(findDuration().text()).toBe(defaultTestCase.formattedTime); }); diff --git a/spec/frontend/pipelines/test_reports/test_reports_spec.js b/spec/frontend/pipelines/test_reports/test_reports_spec.js index e0daf8cb4b5..3c3143b1865 100644 --- a/spec/frontend/pipelines/test_reports/test_reports_spec.js +++ b/spec/frontend/pipelines/test_reports/test_reports_spec.js @@ -31,18 +31,30 @@ describe('Test reports app', () => { const createComponent = ({ state = {} } = {}) => { store = new Vuex.Store({ - state: { - isLoading: false, - selectedSuiteIndex: null, - testReports, - ...state, + modules: { + testReports: { + namespaced: true, + state: { + isLoading: false, + selectedSuiteIndex: null, + testReports, + ...state, + }, + actions: actionSpies, + getters, + }, }, - actions: actionSpies, - getters, }); + jest.spyOn(store, 'registerModule').mockReturnValue(null); + wrapper = extendedWrapper( shallowMount(TestReports, { + provide: { + blobPath: '/blob/path', + summaryEndpoint: '/summary.json', + suiteEndpoint: '/suite.json', + }, store, }), ); diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js index 25650b24705..c372ac06c35 100644 --- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js +++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js @@ -34,22 +34,32 @@ describe('Test reports suite table', () => { const createComponent = ({ suite = testSuite, perPage = 20, errorMessage } = {}) => { store = new Vuex.Store({ - state: { - blobPath, + modules: { testReports: { - test_suites: [suite], + namespaced: true, + state: { + blobPath, + testReports: { + test_suites: [suite], + }, + selectedSuiteIndex: 0, + pageInfo: { + page: 1, + perPage, + }, + errorMessage, + }, + getters, }, - selectedSuiteIndex: 0, - pageInfo: { - page: 1, - perPage, - }, - errorMessage, }, - getters, }); wrapper = shallowMountExtended(SuiteTable, { + provide: { + blobPath: '/blob/path', + summaryEndpoint: '/summary.json', + suiteEndpoint: '/suite.json', + }, store, stubs: { GlFriendlyWrap }, }); diff --git a/spec/frontend/pipelines/test_reports/test_summary_table_spec.js b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js index 1598d5c337f..0e1229f7067 100644 --- a/spec/frontend/pipelines/test_reports/test_summary_table_spec.js +++ b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js @@ -20,13 +20,23 @@ describe('Test reports summary table', () => { const createComponent = (reports = null) => { store = new Vuex.Store({ - state: { - testReports: reports || testReports, + modules: { + testReports: { + namespaced: true, + state: { + testReports: reports || testReports, + }, + getters, + }, }, - getters, }); wrapper = mount(SummaryTable, { + provide: { + blobPath: '/blob/path', + summaryEndpoint: '/summary.json', + suiteEndpoint: '/suite.json', + }, propsData: defaultProps, store, }); diff --git a/spec/frontend/pipelines/utils_spec.js b/spec/frontend/pipelines/utils_spec.js index 1c23a7e4fcf..a82390fae22 100644 --- a/spec/frontend/pipelines/utils_spec.js +++ b/spec/frontend/pipelines/utils_spec.js @@ -8,10 +8,14 @@ import { removeOrphanNodes, getMaxNodes, } from '~/pipelines/components/parsing_utils'; -import { createNodeDict } from '~/pipelines/utils'; +import { createNodeDict, calculateJobStats, calculateSlowestFiveJobs } from '~/pipelines/utils'; import { mockParsedGraphQLNodes, missingJob } from './components/dag/mock_data'; -import { generateResponse, mockPipelineResponse } from './graph/mock_data'; +import { + generateResponse, + mockPipelineResponse, + mockPerformanceInsightsResponse, +} from './graph/mock_data'; describe('DAG visualization parsing utilities', () => { const nodeDict = createNodeDict(mockParsedGraphQLNodes); @@ -158,4 +162,40 @@ describe('DAG visualization parsing utilities', () => { expect(columns).toMatchSnapshot(); }); }); + + describe('performance insights', () => { + const { + data: { + project: { + pipeline: { jobs }, + }, + }, + } = mockPerformanceInsightsResponse; + + describe('calculateJobStats', () => { + const expectedJob = jobs.nodes[0]; + + it('returns the job that spent this longest time queued', () => { + expect(calculateJobStats(jobs, 'queuedDuration')).toEqual(expectedJob); + }); + + it('returns the job that was executed last', () => { + expect(calculateJobStats(jobs, 'startedAt')).toEqual(expectedJob); + }); + }); + + describe('calculateSlowestFiveJobs', () => { + it('returns the slowest five jobs of the pipeline', () => { + const expectedJobs = [ + jobs.nodes[9], + jobs.nodes[1], + jobs.nodes[5], + jobs.nodes[7], + jobs.nodes[8], + ]; + + expect(calculateSlowestFiveJobs(jobs)).toEqual(expectedJobs); + }); + }); + }); }); diff --git a/spec/frontend/projects/new/components/new_project_url_select_spec.js b/spec/frontend/projects/new/components/new_project_url_select_spec.js index ba22622e1f7..b6d4ee32cf5 100644 --- a/spec/frontend/projects/new/components/new_project_url_select_spec.js +++ b/spec/frontend/projects/new/components/new_project_url_select_spec.js @@ -4,6 +4,7 @@ import { GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType, + GlTruncate, } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; @@ -15,7 +16,6 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import eventHub from '~/projects/new/event_hub'; import NewProjectUrlSelect from '~/projects/new/components/new_project_url_select.vue'; import searchQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql'; -import { s__ } from '~/locale'; describe('NewProjectUrlSelect component', () => { let wrapper; @@ -90,6 +90,7 @@ describe('NewProjectUrlSelect component', () => { const findButtonLabel = () => wrapper.findComponent(GlButton); const findDropdown = () => wrapper.findComponent(GlDropdown); + const findSelectedPath = () => wrapper.findComponent(GlTruncate); const findInput = () => wrapper.findComponent(GlSearchBoxByType); const findHiddenNamespaceInput = () => wrapper.find('[name="project[namespace_id]"]'); @@ -121,14 +122,15 @@ describe('NewProjectUrlSelect component', () => { describe('when namespaceId is provided', () => { beforeEach(() => { - wrapper = mountComponent(); + wrapper = mountComponent({ mountFn: mount }); }); it('renders a dropdown with the given namespace full path as the text', () => { - const dropdownProps = findDropdown().props(); + expect(findSelectedPath().props('text')).toBe(defaultProvide.namespaceFullPath); + }); - expect(dropdownProps.text).toBe(defaultProvide.namespaceFullPath); - expect(dropdownProps.toggleClass).not.toContain('gl-text-gray-500!'); + it('renders a dropdown without the class', () => { + expect(findDropdown().props('toggleClass')).not.toContain('gl-text-gray-500!'); }); it('renders a hidden input with the given namespace id', () => { @@ -150,14 +152,15 @@ describe('NewProjectUrlSelect component', () => { }; beforeEach(() => { - wrapper = mountComponent({ provide }); + wrapper = mountComponent({ provide, mountFn: mount }); }); it("renders a dropdown with the user's namespace full path as the text", () => { - const dropdownProps = findDropdown().props(); + expect(findSelectedPath().props('text')).toBe('Pick a group or namespace'); + }); - expect(dropdownProps.text).toBe(s__('ProjectsNew|Pick a group or namespace')); - expect(dropdownProps.toggleClass).toContain('gl-text-gray-500!'); + it('renders a dropdown with the class', () => { + expect(findDropdown().props('toggleClass')).toContain('gl-text-gray-500!'); }); it("renders a hidden input with the user's namespace id", () => { @@ -236,8 +239,8 @@ describe('NewProjectUrlSelect component', () => { expect(listItems.at(2).text()).toBe(data.currentUser.groups.nodes[2].fullPath); }); - it('sets the selection to the group', async () => { - expect(findDropdown().props('text')).toBe(fullPath); + it('sets the selection to the group', () => { + expect(findSelectedPath().props('text')).toBe(fullPath); }); }); diff --git a/spec/frontend/projects/pipelines/charts/components/app_spec.js b/spec/frontend/projects/pipelines/charts/components/app_spec.js index 98c7856a61a..7b9011fa3d9 100644 --- a/spec/frontend/projects/pipelines/charts/components/app_spec.js +++ b/spec/frontend/projects/pipelines/charts/components/app_spec.js @@ -14,6 +14,7 @@ jest.mock('~/lib/utils/url_utility'); const DeploymentFrequencyChartsStub = { name: 'DeploymentFrequencyCharts', render: () => {} }; const LeadTimeChartsStub = { name: 'LeadTimeCharts', render: () => {} }; const TimeToRestoreServiceChartsStub = { name: 'TimeToRestoreServiceCharts', render: () => {} }; +const ChangeFailureRateChartsStub = { name: 'ChangeFailureRateCharts', render: () => {} }; const ProjectQualitySummaryStub = { name: 'ProjectQualitySummary', render: () => {} }; describe('ProjectsPipelinesChartsApp', () => { @@ -33,6 +34,7 @@ describe('ProjectsPipelinesChartsApp', () => { DeploymentFrequencyCharts: DeploymentFrequencyChartsStub, LeadTimeCharts: LeadTimeChartsStub, TimeToRestoreServiceCharts: TimeToRestoreServiceChartsStub, + ChangeFailureRateCharts: ChangeFailureRateChartsStub, ProjectQualitySummary: ProjectQualitySummaryStub, }, }, @@ -50,6 +52,7 @@ describe('ProjectsPipelinesChartsApp', () => { const findGlTabAtIndex = (index) => findAllGlTabs().at(index); const findLeadTimeCharts = () => wrapper.find(LeadTimeChartsStub); const findTimeToRestoreServiceCharts = () => wrapper.find(TimeToRestoreServiceChartsStub); + const findChangeFailureRateCharts = () => wrapper.find(ChangeFailureRateChartsStub); const findDeploymentFrequencyCharts = () => wrapper.find(DeploymentFrequencyChartsStub); const findPipelineCharts = () => wrapper.find(PipelineCharts); const findProjectQualitySummary = () => wrapper.find(ProjectQualitySummaryStub); @@ -59,58 +62,49 @@ describe('ProjectsPipelinesChartsApp', () => { createComponent(); }); - it('renders tabs', () => { - expect(findGlTabs().exists()).toBe(true); + describe.each` + title | finderFn | index + ${'Pipelines'} | ${findPipelineCharts} | ${0} + ${'Deployment frequency'} | ${findDeploymentFrequencyCharts} | ${1} + ${'Lead time'} | ${findLeadTimeCharts} | ${2} + ${'Time to restore service'} | ${findTimeToRestoreServiceCharts} | ${3} + ${'Change failure rate'} | ${findChangeFailureRateCharts} | ${4} + ${'Project quality'} | ${findProjectQualitySummary} | ${5} + `('Tabs', ({ title, finderFn, index }) => { + it(`renders tab with a title ${title} at index ${index}`, () => { + expect(findGlTabAtIndex(index).attributes('title')).toBe(title); + }); - expect(findGlTabAtIndex(0).attributes('title')).toBe('Pipelines'); - expect(findGlTabAtIndex(1).attributes('title')).toBe('Deployment frequency'); - expect(findGlTabAtIndex(2).attributes('title')).toBe('Lead time'); - expect(findGlTabAtIndex(3).attributes('title')).toBe('Time to restore service'); - }); + it(`renders the ${title} chart`, () => { + expect(finderFn().exists()).toBe(true); + }); - it('renders the pipeline charts', () => { - expect(findPipelineCharts().exists()).toBe(true); - }); + it(`updates the current tab and url when the ${title} tab is clicked`, async () => { + let chartsPath; + const tabName = title.toLowerCase().replace(/\s/g, '-'); - it('renders the deployment frequency charts', () => { - expect(findDeploymentFrequencyCharts().exists()).toBe(true); - }); + setWindowLocation(`${TEST_HOST}/gitlab-org/gitlab-test/-/pipelines/charts`); - it('renders the lead time charts', () => { - expect(findLeadTimeCharts().exists()).toBe(true); - }); + mergeUrlParams.mockImplementation(({ chart }, path) => { + expect(chart).toBe(tabName); + expect(path).toBe(window.location.pathname); + chartsPath = `${path}?chart=${chart}`; + return chartsPath; + }); - it('renders the time to restore service charts', () => { - expect(findTimeToRestoreServiceCharts().exists()).toBe(true); - }); + updateHistory.mockImplementation(({ url }) => { + expect(url).toBe(chartsPath); + }); + const tabs = findGlTabs(); - it('renders the project quality summary', () => { - expect(findProjectQualitySummary().exists()).toBe(true); - }); + expect(tabs.attributes('value')).toBe('0'); - it('sets the tab and url when a tab is clicked', async () => { - let chartsPath; - setWindowLocation(`${TEST_HOST}/gitlab-org/gitlab-test/-/pipelines/charts`); + tabs.vm.$emit('input', index); - mergeUrlParams.mockImplementation(({ chart }, path) => { - expect(chart).toBe('deployment-frequency'); - expect(path).toBe(window.location.pathname); - chartsPath = `${path}?chart=${chart}`; - return chartsPath; - }); + await nextTick(); - updateHistory.mockImplementation(({ url }) => { - expect(url).toBe(chartsPath); + expect(tabs.attributes('value')).toBe(index.toString()); }); - const tabs = findGlTabs(); - - expect(tabs.attributes('value')).toBe('0'); - - tabs.vm.$emit('input', 1); - - await nextTick(); - - expect(tabs.attributes('value')).toBe('1'); }); it('should not try to push history if the tab does not change', async () => { @@ -136,6 +130,7 @@ describe('ProjectsPipelinesChartsApp', () => { ${'deployment-frequency-tab'} | ${'p_analytics_ci_cd_deployment_frequency'} ${'lead-time-tab'} | ${'p_analytics_ci_cd_lead_time'} ${'time-to-restore-service-tab'} | ${'p_analytics_ci_cd_time_to_restore_service'} + ${'change-failure-rate-tab'} | ${'p_analytics_ci_cd_change_failure_rate'} `('tracks the $event event when clicked', ({ testId, event }) => { jest.spyOn(API, 'trackRedisHllUserEvent'); @@ -151,6 +146,7 @@ describe('ProjectsPipelinesChartsApp', () => { describe('when provided with a query param', () => { it.each` chart | tab + ${'change-failure-rate'} | ${'4'} ${'time-to-restore-service'} | ${'3'} ${'lead-time'} | ${'2'} ${'deployment-frequency'} | ${'1'} diff --git a/spec/frontend/projects/settings/components/new_access_dropdown_spec.js b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js index a42891423cd..1db48ce05d7 100644 --- a/spec/frontend/projects/settings/components/new_access_dropdown_spec.js +++ b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js @@ -29,9 +29,20 @@ jest.mock('~/projects/settings/api/access_dropdown_api', () => ({ }), getDeployKeys: jest.fn().mockResolvedValue({ data: [ - { id: 10, title: 'key10', fingerprint: 'abcdefghijklmnop', owner: { name: 'user1' } }, - { id: 11, title: 'key11', fingerprint: 'abcdefghijklmnop', owner: { name: 'user2' } }, - { id: 12, title: 'key12', fingerprint: 'abcdefghijklmnop', owner: { name: 'user3' } }, + { + id: 10, + title: 'key10', + fingerprint: 'md5-abcdefghijklmnop', + fingerprint_sha256: 'sha256-abcdefghijklmnop', + owner: { name: 'user1' }, + }, + { + id: 11, + title: 'key11', + fingerprint_sha256: 'sha256-abcdefghijklmnop', + owner: { name: 'user2' }, + }, + { id: 12, title: 'key12', fingerprint: 'md5-abcdefghijklmnop', owner: { name: 'user3' } }, ], }), })); @@ -279,6 +290,7 @@ describe('Access Level Dropdown', () => { { id: 115, type: 'group', group_id: 5 }, { id: 118, type: 'user', user_id: 8, name: 'user2' }, { id: 121, type: 'deploy_key', deploy_key_id: 11 }, + { id: 122, type: 'deploy_key', deploy_key_id: 12 }, ]; const findSelected = (type) => @@ -309,8 +321,9 @@ describe('Access Level Dropdown', () => { it('should set selected deploy keys as intersection between the server response and preselected mapping some keys', () => { const selectedDeployKeys = findSelected(LEVEL_TYPES.DEPLOY_KEY); - expect(selectedDeployKeys).toHaveLength(1); - expect(selectedDeployKeys.at(0).text()).toContain('key11 (abcdefghijklmn...)'); + expect(selectedDeployKeys).toHaveLength(2); + expect(selectedDeployKeys.at(0).text()).toContain('key11 (sha256-abcdefg...)'); + expect(selectedDeployKeys.at(1).text()).toContain('key12 (md5-abcdefghij...)'); }); }); diff --git a/spec/frontend/protected_branches/protected_branch_edit_spec.js b/spec/frontend/protected_branches/protected_branch_edit_spec.js index d842e00d850..6ef1b58a956 100644 --- a/spec/frontend/protected_branches/protected_branch_edit_spec.js +++ b/spec/frontend/protected_branches/protected_branch_edit_spec.js @@ -114,27 +114,30 @@ describe('ProtectedBranchEdit', () => { }); describe('when clicked', () => { - beforeEach(() => { + beforeEach(async () => { mock.onPatch(TEST_URL, { protected_branch: { [patchParam]: true } }).replyOnce(200, {}); - - toggle.click(); }); - it('checks and disables button', () => { + it('checks and disables button', async () => { + await toggle.click(); + expect(toggle).toHaveClass(IS_CHECKED_CLASS); expect(toggle.querySelector(IS_LOADING_SELECTOR)).not.toBe(null); expect(toggle).toHaveClass(IS_DISABLED_CLASS); }); - it('sends update to BE', () => - axios.waitForAll().then(() => { - // Args are asserted in the `.onPatch` call - expect(mock.history.patch).toHaveLength(1); + it('sends update to BE', async () => { + await toggle.click(); + + await axios.waitForAll(); - expect(toggle).not.toHaveClass(IS_DISABLED_CLASS); - expect(toggle.querySelector(IS_LOADING_SELECTOR)).toBe(null); - expect(createFlash).not.toHaveBeenCalled(); - })); + // Args are asserted in the `.onPatch` call + expect(mock.history.patch).toHaveLength(1); + + expect(toggle).not.toHaveClass(IS_DISABLED_CLASS); + expect(toggle.querySelector(IS_LOADING_SELECTOR)).toBe(null); + expect(createFlash).not.toHaveBeenCalled(); + }); }); describe('when clicked and BE error', () => { @@ -143,10 +146,11 @@ describe('ProtectedBranchEdit', () => { toggle.click(); }); - it('flashes error', () => - axios.waitForAll().then(() => { - expect(createFlash).toHaveBeenCalled(); - })); + it('flashes error', async () => { + await axios.waitForAll(); + + expect(createFlash).toHaveBeenCalled(); + }); }); }); }); diff --git a/spec/frontend/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js index e1fc60f0d92..882cb2c1199 100644 --- a/spec/frontend/ref/components/ref_selector_spec.js +++ b/spec/frontend/ref/components/ref_selector_spec.js @@ -162,9 +162,9 @@ describe('Ref selector component', () => { }); describe('initialization behavior', () => { - beforeEach(createComponent); - it('initializes the dropdown with branches and tags when mounted', () => { + createComponent(); + return waitForRequests().then(() => { expect(branchesApiCallSpy).toHaveBeenCalledTimes(1); expect(tagsApiCallSpy).toHaveBeenCalledTimes(1); @@ -173,6 +173,8 @@ describe('Ref selector component', () => { }); it('shows a spinner while network requests are in progress', () => { + createComponent(); + expect(findLoadingIcon().exists()).toBe(true); return waitForRequests().then(() => { diff --git a/spec/frontend/releases/__snapshots__/util_spec.js.snap b/spec/frontend/releases/__snapshots__/util_spec.js.snap index fd2a8eec4d4..90a33152877 100644 --- a/spec/frontend/releases/__snapshots__/util_spec.js.snap +++ b/spec/frontend/releases/__snapshots__/util_spec.js.snap @@ -57,7 +57,7 @@ Object { "evidences": Array [], "milestones": Array [], "name": "The second release", - "releasedAt": "2019-01-10T00:00:00Z", + "releasedAt": 2019-01-10T00:00:00.000Z, "tagName": "v1.2", "tagPath": "/releases-namespace/releases-project/-/tags/v1.2", "upcomingRelease": true, @@ -188,7 +188,7 @@ Object { }, ], "name": "The first release", - "releasedAt": "2018-12-10T00:00:00Z", + "releasedAt": 2018-12-10T00:00:00.000Z, "tagName": "v1.1", "tagPath": "/releases-namespace/releases-project/-/tags/v1.1", "upcomingRelease": true, @@ -196,10 +196,10 @@ Object { ], "paginationInfo": Object { "__typename": "PageInfo", - "endCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTgtMTItMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyIsImlkIjoiMSJ9", + "endCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTgtMTItMTAgMDA6MDA6MDAuMDAwMDAwMDAwICswMDAwIiwiaWQiOiIxIn0", "hasNextPage": false, "hasPreviousPage": false, - "startCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTktMDEtMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyIsImlkIjoiMiJ9", + "startCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTktMDEtMTAgMDA6MDA6MDAuMDAwMDAwMDAwICswMDAwIiwiaWQiOiIyIn0", }, } `; @@ -267,7 +267,9 @@ Object { }, ], "name": "The first release", + "releasedAt": 2018-12-10T00:00:00.000Z, "tagName": "v1.1", + "tagPath": "/releases-namespace/releases-project/-/tags/v1.1", }, } `; @@ -400,7 +402,7 @@ Object { }, ], "name": "The first release", - "releasedAt": "2018-12-10T00:00:00Z", + "releasedAt": 2018-12-10T00:00:00.000Z, "tagName": "v1.1", "tagPath": "/releases-namespace/releases-project/-/tags/v1.1", "upcomingRelease": true, diff --git a/spec/frontend/releases/components/app_edit_new_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js index 80be27c92ff..cb044b9e891 100644 --- a/spec/frontend/releases/components/app_edit_new_spec.js +++ b/spec/frontend/releases/components/app_edit_new_spec.js @@ -1,21 +1,24 @@ -import { mount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { merge } from 'lodash'; import Vuex from 'vuex'; import { nextTick } from 'vue'; -import { GlFormCheckbox } from '@gitlab/ui'; -import originalRelease from 'test_fixtures/api/releases/release.json'; +import { GlDatepicker, GlFormCheckbox } from '@gitlab/ui'; +import originalOneReleaseForEditingQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release_for_editing.query.graphql.json'; +import { convertOneReleaseGraphQLResponse } from '~/releases/util'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; -import * as commonUtils from '~/lib/utils/common_utils'; import ReleaseEditNewApp from '~/releases/components/app_edit_new.vue'; import AssetLinksForm from '~/releases/components/asset_links_form.vue'; +import ConfirmDeleteModal from '~/releases/components/confirm_delete_modal.vue'; import { BACK_URL_PARAM } from '~/releases/constants'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +const originalRelease = originalOneReleaseForEditingQueryResponse.data.project.release; const originalMilestones = originalRelease.milestones; const releasesPagePath = 'path/to/releases/page'; +const upcomingReleaseDocsPath = 'path/to/upcoming/release/docs'; describe('Release edit/new component', () => { let wrapper; @@ -28,22 +31,24 @@ describe('Release edit/new component', () => { const factory = async ({ featureFlags = {}, store: storeUpdates = {} } = {}) => { state = { release, + isExistingRelease: true, markdownDocsPath: 'path/to/markdown/docs', releasesPagePath, projectId: '8', groupId: '42', groupMilestonesAvailable: true, + upcomingReleaseDocsPath, }; actions = { initializeRelease: jest.fn(), saveRelease: jest.fn(), addEmptyAssetLink: jest.fn(), + deleteRelease: jest.fn(), }; getters = { isValid: () => true, - isExistingRelease: () => true, validationErrors: () => ({ assets: { links: [], @@ -68,7 +73,7 @@ describe('Release edit/new component', () => { ), ); - wrapper = mount(ReleaseEditNewApp, { + wrapper = mountExtended(ReleaseEditNewApp, { store, provide: { glFeatures: featureFlags, @@ -88,7 +93,7 @@ describe('Release edit/new component', () => { mock.onGet('/api/v4/projects/8/milestones').reply(200, originalMilestones); - release = commonUtils.convertObjectPropsToCamelCase(originalRelease, { deep: true }); + release = convertOneReleaseGraphQLResponse(originalOneReleaseForEditingQueryResponse).data; }); afterEach(() => { @@ -128,6 +133,18 @@ describe('Release edit/new component', () => { expect(wrapper.find('#release-title').element.value).toBe(release.name); }); + it('renders the released at date in the "Released at" datepicker', () => { + expect(wrapper.findComponent(GlDatepicker).props('value')).toBe(release.releasedAt); + }); + + it('links to the documentation on upcoming releases in the "Released at" description', () => { + const link = wrapper.findByRole('link', { name: 'Upcoming Release' }); + + expect(link.exists()).toBe(true); + + expect(link.attributes('href')).toBe(upcomingReleaseDocsPath); + }); + it('renders the release notes in the "Release notes" textarea', () => { expect(wrapper.find('#release-notes').element.value).toBe(release.description); }); @@ -191,9 +208,7 @@ describe('Release edit/new component', () => { store: { modules: { editNew: { - getters: { - isExistingRelease: () => false, - }, + state: { isExistingRelease: false }, }, }, }, @@ -274,4 +289,31 @@ describe('Release edit/new component', () => { }); }); }); + + describe('delete', () => { + const findConfirmDeleteModal = () => wrapper.findComponent(ConfirmDeleteModal); + + it('calls the deleteRelease action on confirmation', async () => { + await factory(); + findConfirmDeleteModal().vm.$emit('delete'); + + expect(actions.deleteRelease).toHaveBeenCalled(); + }); + + it('is hidden if this is a new release', async () => { + await factory({ + store: { + modules: { + editNew: { + state: { + isExistingRelease: false, + }, + }, + }, + }, + }); + + expect(findConfirmDeleteModal().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/releases/components/app_index_spec.js b/spec/frontend/releases/components/app_index_spec.js index 63ce4c8bb17..f64f07de90e 100644 --- a/spec/frontend/releases/components/app_index_spec.js +++ b/spec/frontend/releases/components/app_index_spec.js @@ -8,6 +8,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; import createFlash from '~/flash'; import { historyPushState } from '~/lib/utils/common_utils'; +import { sprintf, __ } from '~/locale'; import ReleasesIndexApp from '~/releases/components/app_index.vue'; import ReleaseBlock from '~/releases/components/release_block.vue'; import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue'; @@ -15,6 +16,7 @@ import ReleasesEmptyState from '~/releases/components/releases_empty_state.vue'; import ReleasesPagination from '~/releases/components/releases_pagination.vue'; import ReleasesSort from '~/releases/components/releases_sort.vue'; import { PAGE_SIZE, CREATED_ASC, DEFAULT_SORT } from '~/releases/constants'; +import { deleteReleaseSessionKey } from '~/releases/util'; Vue.use(VueApollo); @@ -44,6 +46,7 @@ describe('app_index.vue', () => { let singleRelease; let noReleases; let queryMock; + let toast; const createComponent = ({ singleResponse = Promise.resolve(singleRelease), @@ -58,12 +61,17 @@ describe('app_index.vue', () => { ], ]); + toast = jest.fn(); + wrapper = shallowMountExtended(ReleasesIndexApp, { apolloProvider, provide: { newReleasePath, projectPath, }, + mocks: { + $toast: { show: toast }, + }, }); }; @@ -395,4 +403,27 @@ describe('app_index.vue', () => { }, ); }); + + describe('after deleting', () => { + const release = 'fake release'; + const key = deleteReleaseSessionKey(projectPath); + + beforeEach(async () => { + window.sessionStorage.setItem(key, release); + + await createComponent(); + }); + + it('shows a toast', async () => { + expect(toast).toHaveBeenCalledWith( + sprintf(__('Release %{release} has been successfully deleted.'), { + release, + }), + ); + }); + + it('clears session storage', async () => { + expect(window.sessionStorage.getItem(key)).toBe(null); + }); + }); }); diff --git a/spec/frontend/releases/components/confirm_delete_modal_spec.js b/spec/frontend/releases/components/confirm_delete_modal_spec.js new file mode 100644 index 00000000000..f7c526c1ced --- /dev/null +++ b/spec/frontend/releases/components/confirm_delete_modal_spec.js @@ -0,0 +1,89 @@ +import Vue, { nextTick } from 'vue'; +import Vuex from 'vuex'; +import { GlModal } from '@gitlab/ui'; +import originalOneReleaseForEditingQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release_for_editing.query.graphql.json'; +import { convertOneReleaseGraphQLResponse } from '~/releases/util'; +import ConfirmDeleteModal from '~/releases/components/confirm_delete_modal.vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { __, sprintf } from '~/locale'; + +Vue.use(Vuex); + +const release = convertOneReleaseGraphQLResponse(originalOneReleaseForEditingQueryResponse).data; +const deleteReleaseDocsPath = 'path/to/delete/release/docs'; + +describe('~/releases/components/confirm_delete_modal.vue', () => { + let wrapper; + let state; + + const factory = async () => { + state = { + release, + deleteReleaseDocsPath, + }; + + const store = new Vuex.Store({ + modules: { + editNew: { + namespaced: true, + state, + }, + }, + }); + + wrapper = mountExtended(ConfirmDeleteModal, { + store, + }); + + await nextTick(); + }; + + beforeEach(() => { + factory(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('button', () => { + it('should open the modal on click', async () => { + await wrapper.findByRole('button', { name: 'Delete' }).trigger('click'); + + const title = wrapper.findByText( + sprintf(__('Delete release %{release}?'), { release: release.name }), + ); + + expect(title.exists()).toBe(true); + }); + }); + + describe('modal', () => { + beforeEach(async () => { + await wrapper.findByRole('button', { name: 'Delete' }).trigger('click'); + }); + + it('confirms the user wants to delete the release', () => { + const text = wrapper.findByText(__('Are you sure you want to delete this release?')); + + expect(text.exists()).toBe(true); + }); + + it('links to the tag', () => { + const tagPath = wrapper.findByRole('link', { name: release.tagName }); + expect(tagPath.attributes('href')).toBe(release.tagPath); + }); + + it('links to the docs on deleting releases', () => { + const docsPath = wrapper.findByRole('link', { name: 'Deleting a release' }); + + expect(docsPath.attributes('href')).toBe(deleteReleaseDocsPath); + }); + + it('emits a delete event on action primary', () => { + wrapper.findComponent(GlModal).vm.$emit('primary'); + + expect(wrapper.emitted('delete')).toEqual([[]]); + }); + }); +}); diff --git a/spec/frontend/releases/components/release_block_footer_spec.js b/spec/frontend/releases/components/release_block_footer_spec.js index b095e9e1d78..848e802df4b 100644 --- a/spec/frontend/releases/components/release_block_footer_spec.js +++ b/spec/frontend/releases/components/release_block_footer_spec.js @@ -2,14 +2,16 @@ import { GlLink, GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { cloneDeep } from 'lodash'; import { nextTick } from 'vue'; -import originalRelease from 'test_fixtures/api/releases/release.json'; +import originalOneReleaseQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release.query.graphql.json'; +import { convertOneReleaseGraphQLResponse } from '~/releases/util'; import { trimText } from 'helpers/text_helper'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue'; // TODO: Encapsulate date helpers https://gitlab.com/gitlab-org/gitlab/-/issues/320883 const MONTHS_IN_MS = 1000 * 60 * 60 * 24 * 31; -const mockFutureDate = new Date(new Date().getTime() + MONTHS_IN_MS).toISOString(); +const mockFutureDate = new Date(new Date().getTime() + MONTHS_IN_MS); + +const originalRelease = convertOneReleaseGraphQLResponse(originalOneReleaseQueryResponse).data; describe('Release block footer', () => { let wrapper; @@ -18,7 +20,7 @@ describe('Release block footer', () => { const factory = async (props = {}) => { wrapper = mount(ReleaseBlockFooter, { propsData: { - ...convertObjectPropsToCamelCase(release, { deep: true }), + ...originalRelease, ...props, }, }); @@ -55,8 +57,8 @@ describe('Release block footer', () => { const commitLink = commitInfoSectionLink(); expect(commitLink.exists()).toBe(true); - expect(commitLink.text()).toBe(release.commit.short_id); - expect(commitLink.attributes('href')).toBe(release.commit_path); + expect(commitLink.text()).toBe(release.commit.shortId); + expect(commitLink.attributes('href')).toBe(release.commitPath); }); it('renders the tag icon', () => { @@ -70,8 +72,8 @@ describe('Release block footer', () => { const commitLink = tagInfoSection().find(GlLink); expect(commitLink.exists()).toBe(true); - expect(commitLink.text()).toBe(release.tag_name); - expect(commitLink.attributes('href')).toBe(release.tag_path); + expect(commitLink.text()).toBe(release.tagName); + expect(commitLink.attributes('href')).toBe(release.tagPath); }); it('renders the author and creation time info', () => { @@ -114,14 +116,14 @@ describe('Release block footer', () => { const avatarImg = authorDateInfoSection().find('img'); expect(avatarImg.exists()).toBe(true); - expect(avatarImg.attributes('src')).toBe(release.author.avatar_url); + expect(avatarImg.attributes('src')).toBe(release.author.avatarUrl); }); it("renders a link to the author's profile", () => { const authorLink = authorDateInfoSection().find(GlLink); expect(authorLink.exists()).toBe(true); - expect(authorLink.attributes('href')).toBe(release.author.web_url); + expect(authorLink.attributes('href')).toBe(release.author.webUrl); }); }); @@ -138,7 +140,7 @@ describe('Release block footer', () => { it('renders the commit SHA as plain text (instead of a link)', () => { expect(commitInfoSectionLink().exists()).toBe(false); - expect(commitInfoSection().text()).toBe(release.commit.short_id); + expect(commitInfoSection().text()).toBe(release.commit.shortId); }); }); @@ -155,7 +157,7 @@ describe('Release block footer', () => { it('renders the tag name as plain text (instead of a link)', () => { expect(tagInfoSectionLink().exists()).toBe(false); - expect(tagInfoSection().text()).toBe(release.tag_name); + expect(tagInfoSection().text()).toBe(release.tagName); }); }); diff --git a/spec/frontend/releases/components/release_block_spec.js b/spec/frontend/releases/components/release_block_spec.js index c4910ae9b2f..17e2af687a6 100644 --- a/spec/frontend/releases/components/release_block_spec.js +++ b/spec/frontend/releases/components/release_block_spec.js @@ -1,7 +1,8 @@ import { mount } from '@vue/test-utils'; import $ from 'jquery'; import { nextTick } from 'vue'; -import originalRelease from 'test_fixtures/api/releases/release.json'; +import originalOneReleaseQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release.query.graphql.json'; +import { convertOneReleaseGraphQLResponse } from '~/releases/util'; import * as commonUtils from '~/lib/utils/common_utils'; import * as urlUtility from '~/lib/utils/url_utility'; import EvidenceBlock from '~/releases/components/evidence_block.vue'; @@ -34,7 +35,7 @@ describe('Release block', () => { beforeEach(() => { jest.spyOn($.fn, 'renderGFM'); - release = commonUtils.convertObjectPropsToCamelCase(originalRelease, { deep: true }); + release = convertOneReleaseGraphQLResponse(originalOneReleaseQueryResponse).data; }); afterEach(() => { diff --git a/spec/frontend/releases/components/tag_field_spec.js b/spec/frontend/releases/components/tag_field_spec.js index db08f874959..e7b9aa4abbb 100644 --- a/spec/frontend/releases/components/tag_field_spec.js +++ b/spec/frontend/releases/components/tag_field_spec.js @@ -9,14 +9,14 @@ describe('releases/components/tag_field', () => { let store; let wrapper; - const createComponent = ({ tagName }) => { + const createComponent = ({ isExistingRelease }) => { store = createStore({ modules: { editNew: createEditNewModule({}), }, }); - store.state.editNew.tagName = tagName; + store.state.editNew.isExistingRelease = isExistingRelease; wrapper = shallowMount(TagField, { store }); }; @@ -31,7 +31,7 @@ describe('releases/components/tag_field', () => { describe('when an existing release is being edited', () => { beforeEach(() => { - createComponent({ tagName: 'v1.0' }); + createComponent({ isExistingRelease: true }); }); it('renders the TagFieldExisting component', () => { @@ -45,7 +45,7 @@ describe('releases/components/tag_field', () => { describe('when a new release is being created', () => { beforeEach(() => { - createComponent({ tagName: null }); + createComponent({ isExistingRelease: false }); }); it('renders the TagFieldNew component', () => { diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js index 41653f62ebf..ce3b690213c 100644 --- a/spec/frontend/releases/stores/modules/detail/actions_spec.js +++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js @@ -9,10 +9,15 @@ import { ASSET_LINK_TYPE } from '~/releases/constants'; import createReleaseAssetLinkMutation from '~/releases/graphql/mutations/create_release_link.mutation.graphql'; import deleteReleaseAssetLinkMutation from '~/releases/graphql/mutations/delete_release_link.mutation.graphql'; import updateReleaseMutation from '~/releases/graphql/mutations/update_release.mutation.graphql'; +import deleteReleaseMutation from '~/releases/graphql/mutations/delete_release.mutation.graphql'; import * as actions from '~/releases/stores/modules/edit_new/actions'; import * as types from '~/releases/stores/modules/edit_new/mutation_types'; import createState from '~/releases/stores/modules/edit_new/state'; -import { gqClient, convertOneReleaseGraphQLResponse } from '~/releases/util'; +import { + gqClient, + convertOneReleaseGraphQLResponse, + deleteReleaseSessionKey, +} from '~/releases/util'; jest.mock('~/api/tags_api'); @@ -37,19 +42,15 @@ describe('Release edit/new actions', () => { let error; const setupState = (updates = {}) => { - const getters = { - isExistingRelease: true, - }; - state = { ...createState({ projectId: '18', + isExistingRelease: true, tagName: releaseResponse.tag_name, releasesPagePath: 'path/to/releases/page', markdownDocsPath: 'path/to/markdown/docs', markdownPreviewPath: 'path/to/markdown/preview', }), - ...getters, ...updates, }; }; @@ -168,6 +169,15 @@ describe('Release edit/new actions', () => { }); }); + describe('updateReleasedAt', () => { + it(`commits ${types.UPDATE_RELEASED_AT} with the updated date`, () => { + const newDate = new Date(); + return testAction(actions.updateReleasedAt, newDate, state, [ + { type: types.UPDATE_RELEASED_AT, payload: newDate }, + ]); + }); + }); + describe('updateCreateFrom', () => { it(`commits ${types.UPDATE_CREATE_FROM} with the updated ref`, () => { const newRef = 'my-feature-branch'; @@ -177,6 +187,15 @@ describe('Release edit/new actions', () => { }); }); + describe('updateShowCreateFrom', () => { + it(`commits ${types.UPDATE_SHOW_CREATE_FROM} with the updated ref`, () => { + const newRef = 'my-feature-branch'; + return testAction(actions.updateShowCreateFrom, newRef, state, [ + { type: types.UPDATE_SHOW_CREATE_FROM, payload: newRef }, + ]); + }); + }); + describe('updateReleaseTitle', () => { it(`commits ${types.UPDATE_RELEASE_TITLE} with the updated release title`, () => { const newTitle = 'The new release title'; @@ -572,6 +591,133 @@ describe('Release edit/new actions', () => { }); }); + describe('deleteRelease', () => { + let getters; + let dispatch; + let commit; + let release; + + beforeEach(() => { + getters = { + releaseDeleteMutationVariables: { + input: { + projectPath: 'test-org/test', + tagName: 'v1.0', + }, + }, + }; + + release = convertOneReleaseGraphQLResponse(releaseResponse).data; + + setupState({ + release, + originalRelease: release, + ...getters, + }); + + dispatch = jest.fn(); + commit = jest.fn(); + + gqClient.mutate.mockResolvedValue({ + data: { + releaseDelete: { + errors: [], + }, + releaseAssetLinkDelete: { + errors: [], + }, + }, + }); + }); + + describe('when the delete is successful', () => { + beforeEach(() => { + window.sessionStorage.clear(); + }); + + it('dispatches receiveSaveReleaseSuccess', async () => { + await actions.deleteRelease({ commit, dispatch, state, getters }); + expect(dispatch.mock.calls).toEqual([ + ['receiveSaveReleaseSuccess', state.releasesPagePath], + ]); + }); + + it('deletes the release', async () => { + await actions.deleteRelease({ commit, dispatch, state, getters }); + expect(gqClient.mutate.mock.calls[0]).toEqual([ + { + mutation: deleteReleaseMutation, + variables: getters.releaseDeleteMutationVariables, + }, + ]); + }); + + it('stores the name for toasting', async () => { + await actions.deleteRelease({ commit, dispatch, state, getters }); + expect(window.sessionStorage.getItem(deleteReleaseSessionKey(state.projectPath))).toBe( + state.release.name, + ); + }); + }); + + describe('when the delete request fails', () => { + beforeEach(() => { + gqClient.mutate.mockRejectedValue(error); + }); + + it('dispatches requestDeleteRelease and receiveSaveReleaseError with an error object', async () => { + await actions.deleteRelease({ commit, dispatch, state, getters }); + + expect(commit.mock.calls).toContainEqual([types.RECEIVE_SAVE_RELEASE_ERROR, error]); + }); + + it('shows a flash message', async () => { + await actions.deleteRelease({ commit, dispatch, state, getters }); + + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Something went wrong while deleting the release.', + }); + }); + }); + + describe('when the delete returns errors', () => { + beforeEach(() => { + gqClient.mutate.mockResolvedValue({ + data: { + releaseUpdate: { + errors: ['Something went wrong!'], + }, + releaseAssetLinkDelete: { + errors: [], + }, + releaseAssetLinkCreate: { + errors: [], + }, + }, + }); + }); + + it('dispatches requestDeleteRelease and receiveSaveReleaseError with an error object', async () => { + await actions.deleteRelease({ commit, dispatch, state, getters }); + + expect(commit.mock.calls).toContainEqual([ + types.RECEIVE_SAVE_RELEASE_ERROR, + expect.any(Error), + ]); + }); + + it('shows a flash message', async () => { + await actions.deleteRelease({ commit, dispatch, state, getters }); + + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Something went wrong while deleting the release.', + }); + }); + }); + }); + describe('fetchTagNotes', () => { const tagName = 'v8.0.0'; diff --git a/spec/frontend/releases/stores/modules/detail/getters_spec.js b/spec/frontend/releases/stores/modules/detail/getters_spec.js index c42c6c00f56..4ac6eaebaa2 100644 --- a/spec/frontend/releases/stores/modules/detail/getters_spec.js +++ b/spec/frontend/releases/stores/modules/detail/getters_spec.js @@ -2,20 +2,6 @@ import { s__ } from '~/locale'; import * as getters from '~/releases/stores/modules/edit_new/getters'; describe('Release edit/new getters', () => { - describe('isExistingRelease', () => { - it('returns true if the release is an existing release that already exists in the database', () => { - const state = { tagName: 'test-tag-name' }; - - expect(getters.isExistingRelease(state)).toBe(true); - }); - - it('returns false if the release is a new release that has not yet been saved to the database', () => { - const state = { tagName: null }; - - expect(getters.isExistingRelease(state)).toBe(false); - }); - }); - describe('releaseLinksToCreate', () => { it("returns an empty array if state.release doesn't exist", () => { const state = {}; @@ -302,6 +288,7 @@ describe('Release edit/new getters', () => { name: 'release.name', description: 'release.description', milestones: ['release.milestone[0].title'], + releasedAt: new Date(2022, 5, 30), }, }, { @@ -310,6 +297,7 @@ describe('Release edit/new getters', () => { name: 'release.name', description: 'release.description', milestones: ['release.milestone[0].title'], + releasedAt: new Date(2022, 5, 30), }, ], [ @@ -381,6 +369,26 @@ describe('Release edit/new getters', () => { }); }); + describe('releaseDeleteMutationVariables', () => { + it('returns all the data needed for the releaseDelete GraphQL mutation', () => { + const state = { + projectPath: 'test-org/test', + release: { tagName: 'v1.0' }, + }; + + const expectedVariables = { + input: { + projectPath: 'test-org/test', + tagName: 'v1.0', + }, + }; + + const actualVariables = getters.releaseDeleteMutationVariables(state); + + expect(actualVariables).toEqual(expectedVariables); + }); + }); + describe('formattedReleaseNotes', () => { it.each` description | includeTagNotes | tagNotes | included diff --git a/spec/frontend/releases/stores/modules/detail/mutations_spec.js b/spec/frontend/releases/stores/modules/detail/mutations_spec.js index 85844831e0b..60b57c7a7ff 100644 --- a/spec/frontend/releases/stores/modules/detail/mutations_spec.js +++ b/spec/frontend/releases/stores/modules/detail/mutations_spec.js @@ -25,11 +25,12 @@ describe('Release edit/new mutations', () => { mutations[types.INITIALIZE_EMPTY_RELEASE](state); expect(state.release).toEqual({ - tagName: null, + tagName: 'v1.3', name: '', description: '', milestones: [], groupMilestones: [], + releasedAt: new Date(), assets: { links: [], }, @@ -82,6 +83,16 @@ describe('Release edit/new mutations', () => { }); }); + describe(`${types.UPDATE_RELEASED_AT}`, () => { + it("updates the release's released at date", () => { + state.release = release; + const newDate = new Date(); + mutations[types.UPDATE_RELEASED_AT](state, newDate); + + expect(state.release.releasedAt).toBe(newDate); + }); + }); + describe(`${types.UPDATE_CREATE_FROM}`, () => { it('updates the ref that the ref will be created from', () => { state.createFrom = 'main'; @@ -92,6 +103,16 @@ describe('Release edit/new mutations', () => { }); }); + describe(`${types.UPDATE_SHOW_CREATE_FROM}`, () => { + it('updates the ref that the ref will be created from', () => { + state.showCreateFrom = true; + const newValue = false; + mutations[types.UPDATE_SHOW_CREATE_FROM](state, newValue); + + expect(state.showCreateFrom).toBe(newValue); + }); + }); + describe(`${types.UPDATE_RELEASE_TITLE}`, () => { it("updates the release's title", () => { state.release = release; diff --git a/spec/frontend/reports/components/grouped_issues_list_spec.js b/spec/frontend/reports/components/grouped_issues_list_spec.js index c6eebf05dd7..95ef0bcbcc7 100644 --- a/spec/frontend/reports/components/grouped_issues_list_spec.js +++ b/spec/frontend/reports/components/grouped_issues_list_spec.js @@ -17,7 +17,6 @@ describe('Grouped Issues List', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); it('renders a smart virtual list with the correct props', () => { @@ -35,13 +34,15 @@ describe('Grouped Issues List', () => { }); describe('without data', () => { - beforeEach(createComponent); + beforeEach(() => { + createComponent(); + }); it.each(['unresolved', 'resolved'])('does not a render a header for %s issues', (issueName) => { expect(findHeading(issueName).exists()).toBe(false); }); - it.each('resolved', 'unresolved')('does not render report items for %s issues', () => { + it.each(['resolved', 'unresolved'])('does not render report items for %s issues', () => { expect(wrapper.find(ReportItem).exists()).toBe(false); }); }); diff --git a/spec/frontend/reports/mock_data/new_failures_report.json b/spec/frontend/reports/mock_data/new_failures_report.json index 8b9c12c6271..438f7c82788 100644 --- a/spec/frontend/reports/mock_data/new_failures_report.json +++ b/spec/frontend/reports/mock_data/new_failures_report.json @@ -8,12 +8,14 @@ { "result": "failure", "name": "Test#sum when a is 1 and b is 2 returns summary", + "file": "spec/file_1.rb", "execution_time": 0.009411, "system_output": "Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in <top (required)>'" }, { "result": "failure", "name": "Test#sum when a is 100 and b is 200 returns summary", + "file": "spec/file_2.rb", "execution_time": 0.000162, "system_output": "Failure/Error: is_expected.to eq(300)\n\n expected: 300\n got: -100\n\n (compared using ==)\n./spec/test_spec.rb:21:in `block (4 levels) in <top (required)>'" } diff --git a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap index 4732d68c8c6..cb56f392ec9 100644 --- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap +++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap @@ -17,7 +17,7 @@ exports[`Repository last commit component renders commit widget 1`] = ` /> <div - class="commit-detail flex-list" + class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-flex-grow-1 gl-min-w-0" > <div class="commit-content qa-commit-content" diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index d498b6f0c4f..2b70cb84c67 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -136,6 +136,7 @@ describe('Blob content viewer component', () => { const findBlobButtonGroup = () => wrapper.findComponent(BlobButtonGroup); const findForkSuggestion = () => wrapper.findComponent(ForkSuggestion); const findCodeIntelligence = () => wrapper.findComponent(CodeIntelligence); + const findSourceViewer = () => wrapper.findComponent(SourceViewer); beforeEach(() => { jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately); @@ -197,6 +198,16 @@ describe('Blob content viewer component', () => { expect(mockAxios.history.get[0].url).toBe(legacyViewerUrl); }); + it('loads a legacy viewer when the source viewer emits an error', async () => { + loadViewer.mockReturnValueOnce(SourceViewer); + await createComponent(); + findSourceViewer().vm.$emit('error'); + await waitForPromises(); + + expect(mockAxios.history.get).toHaveLength(1); + expect(mockAxios.history.get[0].url).toBe(legacyViewerUrl); + }); + it('loads a legacy viewer when a viewer component is not available', async () => { await createComponent({ blob: { ...simpleViewerMock, fileType: 'unknown' } }); diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js index cfbf74e34aa..3783b34e33a 100644 --- a/spec/frontend/repository/components/last_commit_spec.js +++ b/spec/frontend/repository/components/last_commit_spec.js @@ -1,179 +1,227 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; import { GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + import LastCommit from '~/repository/components/last_commit.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; - -let vm; - -function createCommitData(data = {}) { - const defaultData = { - sha: '123456789', - title: 'Commit title', - titleHtml: 'Commit title', - message: 'Commit message', - webPath: '/commit/123', - authoredDate: '2019-01-01', - author: { - name: 'Test', - avatarUrl: 'https://test.com', - webPath: '/test', - }, - pipeline: { +import pathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql'; +import { refMock } from '../mock_data'; + +let wrapper; +let mockResolver; + +const findPipeline = () => wrapper.find('.js-commit-pipeline'); +const findTextExpander = () => wrapper.find('.text-expander'); +const findUserLink = () => wrapper.find('.js-user-link'); +const findUserAvatarLink = () => wrapper.findComponent(UserAvatarLink); +const findLastCommitLabel = () => wrapper.findByTestId('last-commit-id-label'); +const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); +const findCommitRowDescription = () => wrapper.find('.commit-row-description'); +const findStatusBox = () => wrapper.find('.gpg-status-box'); +const findItemTitle = () => wrapper.find('.item-title'); + +const defaultPipelineEdges = [ + { + __typename: 'PipelineEdge', + node: { + __typename: 'Pipeline', + id: 'gid://gitlab/Ci::Pipeline/167', detailedStatus: { + __typename: 'DetailedStatus', + id: 'id', detailsPath: 'https://test.com/pipeline', - icon: 'failed', + icon: 'status_running', tooltip: 'failed', text: 'failed', - group: {}, + group: 'failed', }, }, - }; - return Object.assign(defaultData, data); -} - -function factory(commit = createCommitData(), loading = false) { - vm = shallowMount(LastCommit, { - mocks: { - $apollo: { - queries: { - commit: { - loading: true, + }, +]; + +const defaultAuthor = { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + name: 'Test', + avatarUrl: 'https://test.com', + webPath: '/test', +}; + +const defaultMessage = 'Commit title'; + +const createCommitData = ({ + pipelineEdges = defaultPipelineEdges, + author = defaultAuthor, + descriptionHtml = '', + signatureHtml = null, + message = defaultMessage, +}) => { + return { + data: { + project: { + __typename: 'Project', + id: 'gid://gitlab/Project/6', + repository: { + __typename: 'Repository', + paginatedTree: { + __typename: 'TreeConnection', + nodes: [ + { + __typename: 'Tree', + lastCommit: { + __typename: 'Commit', + id: 'gid://gitlab/CommitPresenter/123456789', + sha: '123456789', + title: 'Commit title', + titleHtml: 'Commit title', + descriptionHtml, + message, + webPath: '/commit/123', + authoredDate: '2019-01-01', + authorName: 'Test', + authorGravatar: 'https://test.com', + author, + signatureHtml, + pipelines: { + __typename: 'PipelineConnection', + edges: pipelineEdges, + }, + }, + }, + ], }, }, }, }, - }); - // 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; -} + }; +}; -const emptyMessageClass = 'font-italic'; +const createComponent = async (data = {}) => { + Vue.use(VueApollo); -describe('Repository last commit component', () => { - afterEach(() => { - vm.destroy(); + const currentPath = 'path'; + + mockResolver = jest.fn().mockResolvedValue(createCommitData(data)); + + wrapper = shallowMountExtended(LastCommit, { + apolloProvider: createMockApollo([[pathLastCommitQuery, mockResolver]]), + propsData: { currentPath }, + mixins: [{ data: () => ({ ref: refMock }) }], }); +}; + +afterEach(() => { + wrapper.destroy(); + mockResolver = null; +}); +describe('Repository last commit component', () => { it.each` loading | label ${true} | ${'shows'} ${false} | ${'hides'} - `('$label when loading icon $loading is true', async ({ loading }) => { - factory(createCommitData(), loading); + `('$label when loading icon is $loading', async ({ loading }) => { + createComponent(); - await nextTick(); + if (!loading) { + await waitForPromises(); + } - expect(vm.find(GlLoadingIcon).exists()).toBe(loading); + expect(findLoadingIcon().exists()).toBe(loading); }); it('renders commit widget', async () => { - factory(); + createComponent(); + await waitForPromises(); - await nextTick(); - - expect(vm.element).toMatchSnapshot(); + expect(wrapper.element).toMatchSnapshot(); }); it('renders short commit ID', async () => { - factory(); - - await nextTick(); + createComponent(); + await waitForPromises(); - expect(vm.find('[data-testid="last-commit-id-label"]').text()).toEqual('12345678'); + expect(findLastCommitLabel().text()).toBe('12345678'); }); it('hides pipeline components when pipeline does not exist', async () => { - factory(createCommitData({ pipeline: null })); + createComponent({ pipelineEdges: [] }); + await waitForPromises(); - await nextTick(); - - expect(vm.find('.js-commit-pipeline').exists()).toBe(false); + expect(findPipeline().exists()).toBe(false); }); - it('renders pipeline components', async () => { - factory(); - - await nextTick(); + it('renders pipeline components when pipeline exists', async () => { + createComponent(); + await waitForPromises(); - expect(vm.find('.js-commit-pipeline').exists()).toBe(true); + expect(findPipeline().exists()).toBe(true); }); it('hides author component when author does not exist', async () => { - factory(createCommitData({ author: null })); + createComponent({ author: null }); + await waitForPromises(); - await nextTick(); - - expect(vm.find('.js-user-link').exists()).toBe(false); - expect(vm.find(UserAvatarLink).exists()).toBe(false); + expect(findUserLink().exists()).toBe(false); + expect(findUserAvatarLink().exists()).toBe(false); }); it('does not render description expander when description is null', async () => { - factory(createCommitData({ descriptionHtml: null })); - - await nextTick(); + createComponent(); + await waitForPromises(); - expect(vm.find('.text-expander').exists()).toBe(false); - expect(vm.find('.commit-row-description').exists()).toBe(false); + expect(findTextExpander().exists()).toBe(false); + expect(findCommitRowDescription().exists()).toBe(false); }); - it('expands commit description when clicking expander', async () => { - factory(createCommitData({ descriptionHtml: 'Test description' })); - - await nextTick(); - - vm.find('.text-expander').vm.$emit('click'); - - await nextTick(); - - expect(vm.find('.commit-row-description').isVisible()).toBe(true); - expect(vm.find('.text-expander').classes('open')).toBe(true); - }); - - it('strips the first newline of the description', async () => { - factory(createCommitData({ descriptionHtml: '
Update ADOPTERS.md' })); - - await nextTick(); - - expect(vm.find('.commit-row-description').html()).toBe( - '<pre class="commit-row-description gl-mb-3">Update ADOPTERS.md</pre>', - ); + describe('when the description is present', () => { + beforeEach(async () => { + createComponent({ descriptionHtml: '
Update ADOPTERS.md' }); + await waitForPromises(); + }); + + it('strips the first newline of the description', () => { + expect(findCommitRowDescription().html()).toBe( + '<pre class="commit-row-description gl-mb-3">Update ADOPTERS.md</pre>', + ); + }); + + it('expands commit description when clicking expander', async () => { + findTextExpander().vm.$emit('click'); + await nextTick(); + + expect(findCommitRowDescription().isVisible()).toBe(true); + expect(findTextExpander().classes()).toContain('open'); + }); }); it('renders the signature HTML as returned by the backend', async () => { - factory( - createCommitData({ - signatureHtml: `<a - class="btn gpg-status-box valid" - data-content="signature-content" - data-html="true" - data-placement="top" - data-title="signature-title" - data-toggle="popover" - role="button" - tabindex="0" - > - Verified - </a>`, - }), - ); - - await nextTick(); - - expect(vm.find('.gpg-status-box').html()).toBe( - `<a class="btn gpg-status-box valid" data-content="signature-content" data-html="true" data-placement="top" data-title="signature-title" data-toggle="popover" role="button" tabindex="0"> - Verified -</a>`, + createComponent({ + signatureHtml: `<a + class="btn gpg-status-box valid" + data-content="signature-content" + data-html="true" + data-placement="top" + data-title="signature-title" + data-toggle="popover" + role="button" + tabindex="0" + >Verified</a>`, + }); + await waitForPromises(); + + expect(findStatusBox().html()).toBe( + `<a class="btn gpg-status-box valid" data-content="signature-content" data-html="true" data-placement="top" data-title="signature-title" data-toggle="popover" role="button" tabindex="0">Verified</a>`, ); }); it('sets correct CSS class if the commit message is empty', async () => { - factory(createCommitData({ message: '' })); - - await nextTick(); + createComponent({ message: '' }); + await waitForPromises(); - expect(vm.find('.item-title').classes()).toContain(emptyMessageClass); + expect(findItemTitle().classes()).toContain('font-italic'); }); }); diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js index 22570b2d6ed..13b09e57473 100644 --- a/spec/frontend/repository/components/table/row_spec.js +++ b/spec/frontend/repository/components/table/row_spec.js @@ -31,7 +31,7 @@ function factory(propsData = {}) { GlHoverLoad: createMockDirective(), }, provide: { - glFeatures: { refactorBlobViewer: true, lazyLoadCommits: true }, + glFeatures: { lazyLoadCommits: true }, }, mocks: { $router, @@ -244,8 +244,6 @@ describe('Repository table row component', () => { }); describe('row visibility', () => { - beforeAll(() => jest.useFakeTimers()); - beforeEach(() => { factory({ id: '1', @@ -260,12 +258,13 @@ describe('Repository table row component', () => { afterAll(() => jest.useRealTimers()); it('emits a `row-appear` event', async () => { + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); findIntersectionObserver().vm.$emit('appear'); jest.runAllTimers(); - expect(setTimeout).toHaveBeenCalledTimes(1); - expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), ROW_APPEAR_DELAY); + expect(setTimeoutSpy).toHaveBeenCalledTimes(1); + expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), ROW_APPEAR_DELAY); expect(vm.emitted('row-appear')).toEqual([[123]]); }); }); diff --git a/spec/frontend/repository/log_tree_spec.js b/spec/frontend/repository/log_tree_spec.js index 5186c9a8992..e3b4dcb8acc 100644 --- a/spec/frontend/repository/log_tree_spec.js +++ b/spec/frontend/repository/log_tree_spec.js @@ -16,19 +16,18 @@ const mockData = [ commit_path: `https://test.com`, commit_title_html: 'commit title', file_name: 'index.js', - type: 'blob', }, ]; describe('resolveCommit', () => { it('calls resolve when commit found', () => { const resolver = { - entry: { name: 'index.js', type: 'blob' }, + entry: { name: 'index.js' }, resolve: jest.fn(), }; const commits = [ - { fileName: 'index.js', filePath: '/index.js', type: 'blob' }, - { fileName: 'index.js', filePath: '/app/assets/index.js', type: 'blob' }, + { fileName: 'index.js', filePath: '/index.js' }, + { fileName: 'index.js', filePath: '/app/assets/index.js' }, ]; resolveCommit(commits, '', resolver); @@ -36,7 +35,6 @@ describe('resolveCommit', () => { expect(resolver.resolve).toHaveBeenCalledWith({ fileName: 'index.js', filePath: '/index.js', - type: 'blob', }); }); }); @@ -56,7 +54,7 @@ describe('fetchLogsTree', () => { global.gon = { relative_url_root: '' }; resolver = { - entry: { name: 'index.js', type: 'blob' }, + entry: { name: 'index.js' }, resolve: jest.fn(), }; @@ -119,7 +117,6 @@ describe('fetchLogsTree', () => { filePath: '/index.js', message: 'testing message', sha: '123', - type: 'blob', }), ); })); @@ -136,7 +133,6 @@ describe('fetchLogsTree', () => { message: 'testing message', sha: '123', titleHtml: 'commit title', - type: 'blob', }), ], }); diff --git a/spec/frontend/repository/utils/commit_spec.js b/spec/frontend/repository/utils/commit_spec.js index aaaa39f739f..b3dd5118308 100644 --- a/spec/frontend/repository/utils/commit_spec.js +++ b/spec/frontend/repository/utils/commit_spec.js @@ -10,7 +10,6 @@ const mockData = [ commit_path: `https://test.com`, commit_title_html: 'testing message', file_name: 'index.js', - type: 'blob', }, ]; @@ -24,7 +23,6 @@ describe('normalizeData', () => { commitPath: 'https://test.com', fileName: 'index.js', filePath: '/index.js', - type: 'blob', titleHtml: 'testing message', __typename: 'LogTreeCommit', }, diff --git a/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js b/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js index 28e7d192938..433be5d5027 100644 --- a/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js +++ b/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js @@ -9,6 +9,7 @@ import { redirectTo } from '~/lib/utils/url_utility'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerHeader from '~/runner/components/runner_header.vue'; +import RunnerDetails from '~/runner/components/runner_details.vue'; import RunnerPauseButton from '~/runner/components/runner_pause_button.vue'; import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue'; import RunnerEditButton from '~/runner/components/runner_edit_button.vue'; @@ -37,6 +38,7 @@ describe('AdminRunnerShowApp', () => { let mockRunnerQuery; const findRunnerHeader = () => wrapper.findComponent(RunnerHeader); + const findRunnerDetails = () => wrapper.findComponent(RunnerDetails); const findRunnerDeleteButton = () => wrapper.findComponent(RunnerDeleteButton); const findRunnerEditButton = () => wrapper.findComponent(RunnerEditButton); const findRunnerPauseButton = () => wrapper.findComponent(RunnerPauseButton); @@ -179,12 +181,32 @@ describe('AdminRunnerShowApp', () => { }); }); + describe('When loading', () => { + beforeEach(() => { + mockRunnerQueryResult(); + + createComponent(); + }); + + it('does not show runner details', () => { + expect(findRunnerDetails().exists()).toBe(false); + }); + + it('does not show runner jobs', () => { + expect(findRunnersJobs().exists()).toBe(false); + }); + }); + describe('When there is an error', () => { beforeEach(async () => { mockRunnerQuery = jest.fn().mockRejectedValueOnce(new Error('Error!')); await createComponent(); }); + it('does not show runner details', () => { + expect(findRunnerDetails().exists()).toBe(false); + }); + it('error is reported to sentry', () => { expect(captureException).toHaveBeenCalledWith({ error: new Error('Error!'), @@ -201,13 +223,6 @@ describe('AdminRunnerShowApp', () => { const stubs = { GlTab, GlTabs, - RunnerDetails: { - template: ` - <div> - <slot name="jobs-tab"></slot> - </div> - `, - }, }; it('without a runner, shows no jobs', () => { 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 3d25ad075de..aa1aa723491 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -10,9 +10,11 @@ import { } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; +import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory } from '~/lib/utils/url_utility'; +import { upgradeStatusTokenConfig } from 'ee_else_ce/runner/components/search_tokens/upgrade_status_token_config'; import { createLocalState } from '~/runner/graphql/list/local_state'; import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; @@ -20,6 +22,7 @@ import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_ import RunnerList from '~/runner/components/runner_list.vue'; import RunnerListEmptyState from '~/runner/components/runner_list_empty_state.vue'; import RunnerStats from '~/runner/components/stat/runner_stats.vue'; +import RunnerCount from '~/runner/components/stat/runner_count.vue'; import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue'; import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; import RunnerPagination from '~/runner/components/runner_pagination.vue'; @@ -30,8 +33,6 @@ import { CREATED_DESC, DEFAULT_SORT, INSTANCE_TYPE, - GROUP_TYPE, - PROJECT_TYPE, PARAM_KEY_PAUSED, PARAM_KEY_STATUS, PARAM_KEY_TAG, @@ -40,15 +41,14 @@ import { STATUS_STALE, RUNNER_PAGE_SIZE, } from '~/runner/constants'; -import adminRunnersQuery from '~/runner/graphql/list/admin_runners.query.graphql'; -import adminRunnersCountQuery from '~/runner/graphql/list/admin_runners_count.query.graphql'; +import allRunnersQuery from 'ee_else_ce/runner/graphql/list/all_runners.query.graphql'; +import allRunnersCountQuery from '~/runner/graphql/list/all_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, + allRunnersData, runnersCountData, - runnersDataPaginated, + allRunnersDataPaginated, onlineContactTimeoutSecs, staleTimeoutSecs, emptyStateSvgPath, @@ -56,9 +56,12 @@ import { } from '../mock_data'; const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; -const mockRunners = runnersData.data.runners.nodes; +const mockRunners = allRunnersData.data.runners.nodes; const mockRunnersCount = runnersCountData.data.runners.count; +const mockRunnersHandler = jest.fn(); +const mockRunnersCountHandler = jest.fn(); + jest.mock('~/flash'); jest.mock('~/runner/sentry_utils'); jest.mock('~/lib/utils/url_utility', () => ({ @@ -71,8 +74,6 @@ Vue.use(GlToast); describe('AdminRunnersApp', () => { let wrapper; - let mockRunnersQuery; - let mockRunnersCountQuery; let cacheConfig; let localMutations; @@ -85,7 +86,6 @@ describe('AdminRunnersApp', () => { const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page'); const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); - const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); const createComponent = ({ props = {}, @@ -96,8 +96,8 @@ describe('AdminRunnersApp', () => { ({ cacheConfig, localMutations } = createLocalState()); const handlers = [ - [adminRunnersQuery, mockRunnersQuery], - [adminRunnersCountQuery, mockRunnersCountQuery], + [allRunnersQuery, mockRunnersHandler], + [allRunnersCountQuery, mockRunnersCountHandler], ]; wrapper = mountFn(AdminRunnersApp, { @@ -116,110 +116,62 @@ describe('AdminRunnersApp', () => { }, ...options, }); - }; - beforeEach(async () => { - setWindowLocation('/admin/runners'); + return waitForPromises(); + }; - mockRunnersQuery = jest.fn().mockResolvedValue(runnersData); - mockRunnersCountQuery = jest.fn().mockResolvedValue(runnersCountData); - createComponent(); - await waitForPromises(); + beforeEach(() => { + mockRunnersHandler.mockResolvedValue(allRunnersData); + mockRunnersCountHandler.mockResolvedValue(runnersCountData); }); afterEach(() => { - mockRunnersQuery.mockReset(); - mockRunnersCountQuery.mockReset(); + mockRunnersHandler.mockReset(); + mockRunnersCountHandler.mockReset(); wrapper.destroy(); }); 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: mountExtended }); - await waitForPromises(); - - expect(findRunnerTypeTabs().text()).toMatchInterpolatedText( - `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: mountExtended }); - await waitForPromises(); + await createComponent({ mountFn: mountExtended }); expect(findRunnerTypeTabs().text()).toMatchInterpolatedText( - `All 6,000 Instance 3,000 Group 2,000 Project 1,000`, + `All ${mockRunnersCount} Instance ${mockRunnersCount} Group ${mockRunnersCount} Project ${mockRunnersCount}`, ); }); it('shows the runner setup instructions', () => { + createComponent(); + expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken); expect(findRegistrationDropdown().props('type')).toBe(INSTANCE_TYPE); }); it('shows total runner counts', async () => { - expect(mockRunnersCountQuery).toHaveBeenCalledWith({ - status: STATUS_ONLINE, - }); - expect(mockRunnersCountQuery).toHaveBeenCalledWith({ - status: STATUS_OFFLINE, - }); - expect(mockRunnersCountQuery).toHaveBeenCalledWith({ - status: STATUS_STALE, - }); + await createComponent({ mountFn: mountExtended }); - expect(findRunnerStats().props()).toMatchObject({ - onlineRunnersCount: mockRunnersCount, - offlineRunnersCount: mockRunnersCount, - staleRunnersCount: mockRunnersCount, - }); + expect(mockRunnersCountHandler).toHaveBeenCalledWith({ status: STATUS_ONLINE }); + expect(mockRunnersCountHandler).toHaveBeenCalledWith({ status: STATUS_OFFLINE }); + expect(mockRunnersCountHandler).toHaveBeenCalledWith({ status: STATUS_STALE }); + + expect(findRunnerStats().text()).toContain( + `${s__('Runners|Online runners')} ${mockRunnersCount}`, + ); + expect(findRunnerStats().text()).toContain( + `${s__('Runners|Offline runners')} ${mockRunnersCount}`, + ); + expect(findRunnerStats().text()).toContain( + `${s__('Runners|Stale runners')} ${mockRunnersCount}`, + ); }); - it('shows the runners list', () => { + it('shows the runners list', async () => { + await createComponent(); + expect(findRunnerList().props('runners')).toEqual(mockRunners); }); it('runner item links to the runner admin page', async () => { - createComponent({ mountFn: mountExtended }); - - await waitForPromises(); + await createComponent({ mountFn: mountExtended }); const { id, shortSha } = mockRunners[0]; const numericId = getIdFromGraphQLId(id); @@ -231,12 +183,9 @@ describe('AdminRunnersApp', () => { }); it('renders runner actions for each runner', async () => { - createComponent({ mountFn: mountExtended }); - - await waitForPromises(); + await createComponent({ mountFn: mountExtended }); const runnerActions = wrapper.find('tr [data-testid="td-actions"]').find(RunnerActionsCell); - const runner = mockRunners[0]; expect(runnerActions.props()).toEqual({ @@ -245,8 +194,10 @@ describe('AdminRunnersApp', () => { }); }); - it('requests the runners with no filters', () => { - expect(mockRunnersQuery).toHaveBeenLastCalledWith({ + it('requests the runners with no filters', async () => { + await createComponent(); + + expect(mockRunnersHandler).toHaveBeenLastCalledWith({ status: undefined, type: undefined, sort: DEFAULT_SORT, @@ -255,9 +206,9 @@ describe('AdminRunnersApp', () => { }); it('sets tokens in the filtered search', () => { - createComponent({ mountFn: mountExtended }); + createComponent(); - expect(findFilteredSearch().props('tokens')).toEqual([ + expect(findRunnerFilteredSearchBar().props('tokens')).toEqual([ expect.objectContaining({ type: PARAM_KEY_PAUSED, options: expect.any(Array), @@ -270,6 +221,7 @@ describe('AdminRunnersApp', () => { type: PARAM_KEY_TAG, recentSuggestionsStorageKey: `${ADMIN_FILTERED_SEARCH_NAMESPACE}-recent-tags`, }), + upgradeStatusTokenConfig, ]); }); @@ -282,12 +234,10 @@ describe('AdminRunnersApp', () => { const FILTERED_COUNT_QUERIES = 4; // Smart queries that display a count of runners in tabs beforeEach(async () => { - mockRunnersCountQuery.mockClear(); + mockRunnersCountHandler.mockClear(); - createComponent({ mountFn: mountExtended }); + await createComponent({ mountFn: mountExtended }); showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show'); - - await waitForPromises(); }); it('Links to the runner page', async () => { @@ -298,12 +248,11 @@ describe('AdminRunnersApp', () => { }); it('When runner is paused or unpaused, some data is refetched', async () => { - expect(mockRunnersCountQuery).toHaveBeenCalledTimes(COUNT_QUERIES); + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); findRunnerActionsCell().vm.$emit('toggledPaused'); - expect(mockRunnersCountQuery).toHaveBeenCalledTimes(COUNT_QUERIES + FILTERED_COUNT_QUERIES); - + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES + FILTERED_COUNT_QUERIES); expect(showToast).toHaveBeenCalledTimes(0); }); @@ -319,8 +268,12 @@ describe('AdminRunnersApp', () => { beforeEach(async () => { setWindowLocation(`?status[]=${STATUS_ONLINE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`); - createComponent(); - await waitForPromises(); + await createComponent({ + stubs: { + RunnerStats, + RunnerCount, + }, + }); }); it('sets the filters in the search bar', () => { @@ -336,7 +289,7 @@ describe('AdminRunnersApp', () => { }); it('requests the runners with filter parameters', () => { - expect(mockRunnersQuery).toHaveBeenLastCalledWith({ + expect(mockRunnersHandler).toHaveBeenLastCalledWith({ status: STATUS_ONLINE, type: INSTANCE_TYPE, tagList: ['tag1'], @@ -346,21 +299,22 @@ describe('AdminRunnersApp', () => { }); it('fetches count results for requested status', () => { - expect(mockRunnersCountQuery).toHaveBeenCalledWith({ + expect(mockRunnersCountHandler).toHaveBeenCalledWith({ type: INSTANCE_TYPE, status: STATUS_ONLINE, tagList: ['tag1'], }); - - expect(findRunnerStats().props()).toMatchObject({ - onlineRunnersCount: mockRunnersCount, - }); }); }); describe('when a filter is selected by the user', () => { beforeEach(() => { - mockRunnersCountQuery.mockClear(); + createComponent({ + stubs: { + RunnerStats, + RunnerCount, + }, + }); findRunnerFilteredSearchBar().vm.$emit('input', { runnerType: null, @@ -375,12 +329,12 @@ describe('AdminRunnersApp', () => { it('updates the browser url', () => { expect(updateHistory).toHaveBeenLastCalledWith({ title: expect.any(String), - url: 'http://test.host/admin/runners?status[]=ONLINE&tag[]=tag1&sort=CREATED_ASC', + url: expect.stringContaining('?status[]=ONLINE&tag[]=tag1&sort=CREATED_ASC'), }); }); it('requests the runners with filters', () => { - expect(mockRunnersQuery).toHaveBeenLastCalledWith({ + expect(mockRunnersHandler).toHaveBeenLastCalledWith({ status: STATUS_ONLINE, tagList: ['tag1'], sort: CREATED_ASC, @@ -389,30 +343,10 @@ describe('AdminRunnersApp', () => { }); it('fetches count results for requested status', () => { - expect(mockRunnersCountQuery).toHaveBeenCalledWith({ + expect(mockRunnersCountHandler).toHaveBeenCalledWith({ tagList: ['tag1'], status: STATUS_ONLINE, }); - - expect(findRunnerStats().props()).toMatchObject({ - onlineRunnersCount: mockRunnersCount, - }); - }); - - it('skips fetching count results for status that were not in filter', () => { - expect(mockRunnersCountQuery).not.toHaveBeenCalledWith({ - tagList: ['tag1'], - status: STATUS_OFFLINE, - }); - expect(mockRunnersCountQuery).not.toHaveBeenCalledWith({ - tagList: ['tag1'], - status: STATUS_STALE, - }); - - expect(findRunnerStats().props()).toMatchObject({ - offlineRunnersCount: null, - staleRunnersCount: null, - }); }); }); @@ -458,14 +392,13 @@ describe('AdminRunnersApp', () => { describe('when no runners are found', () => { beforeEach(async () => { - mockRunnersQuery = jest.fn().mockResolvedValue({ + mockRunnersHandler.mockResolvedValue({ data: { runners: { nodes: [] }, }, }); - createComponent(); - await waitForPromises(); + await createComponent(); }); it('shows an empty state', () => { @@ -490,9 +423,8 @@ describe('AdminRunnersApp', () => { describe('when runners query fails', () => { beforeEach(async () => { - mockRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!')); - createComponent(); - await waitForPromises(); + mockRunnersHandler.mockRejectedValue(new Error('Error!')); + await createComponent(); }); it('error is shown to the user', async () => { @@ -509,19 +441,18 @@ describe('AdminRunnersApp', () => { describe('Pagination', () => { beforeEach(async () => { - mockRunnersQuery = jest.fn().mockResolvedValue(runnersDataPaginated); + mockRunnersHandler.mockResolvedValue(allRunnersDataPaginated); - createComponent({ mountFn: mountExtended }); - await waitForPromises(); + await createComponent({ mountFn: mountExtended }); }); it('navigates to the next page', async () => { await findRunnerPaginationNext().trigger('click'); - expect(mockRunnersQuery).toHaveBeenLastCalledWith({ + expect(mockRunnersHandler).toHaveBeenLastCalledWith({ sort: CREATED_DESC, first: RUNNER_PAGE_SIZE, - after: runnersDataPaginated.data.runners.pageInfo.endCursor, + after: allRunnersDataPaginated.data.runners.pageInfo.endCursor, }); }); }); 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 7a949cb6505..ffd6f126627 100644 --- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js @@ -4,9 +4,9 @@ import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue import RunnerPauseButton from '~/runner/components/runner_pause_button.vue'; import RunnerEditButton from '~/runner/components/runner_edit_button.vue'; import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue'; -import { runnersData } from '../../mock_data'; +import { allRunnersData } from '../../mock_data'; -const mockRunner = runnersData.data.runners.nodes[0]; +const mockRunner = allRunnersData.data.runners.nodes[0]; describe('RunnerActionsCell', () => { let wrapper; diff --git a/spec/frontend/runner/components/runner_delete_button_spec.js b/spec/frontend/runner/components/runner_delete_button_spec.js index b11c749d0a7..52fe803c536 100644 --- a/spec/frontend/runner/components/runner_delete_button_spec.js +++ b/spec/frontend/runner/components/runner_delete_button_spec.js @@ -17,9 +17,9 @@ import { import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue'; import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue'; -import { runnersData } from '../mock_data'; +import { allRunnersData } from '../mock_data'; -const mockRunner = runnersData.data.runners.nodes[0]; +const mockRunner = allRunnersData.data.runners.nodes[0]; const mockRunnerId = getIdFromGraphQLId(mockRunner.id); Vue.use(VueApollo); diff --git a/spec/frontend/runner/components/runner_details_spec.js b/spec/frontend/runner/components/runner_details_spec.js index 9e0f7014750..552ee29b6f9 100644 --- a/spec/frontend/runner/components/runner_details_spec.js +++ b/spec/frontend/runner/components/runner_details_spec.js @@ -25,12 +25,7 @@ describe('RunnerDetails', () => { const findDetailGroups = () => wrapper.findComponent(RunnerGroups); - const createComponent = ({ - props = {}, - stubs, - mountFn = shallowMountExtended, - ...options - } = {}) => { + const createComponent = ({ props = {}, stubs, mountFn = shallowMountExtended } = {}) => { wrapper = mountFn(RunnerDetails, { propsData: { ...props, @@ -39,7 +34,6 @@ describe('RunnerDetails', () => { RunnerDetail, ...stubs, }, - ...options, }); }; @@ -47,16 +41,6 @@ describe('RunnerDetails', () => { wrapper.destroy(); }); - it('when no runner is present, no contents are shown', () => { - createComponent({ - props: { - runner: null, - }, - }); - - expect(wrapper.text()).toBe(''); - }); - describe('Details tab', () => { describe.each` field | runner | expectedValue @@ -141,18 +125,4 @@ describe('RunnerDetails', () => { }); }); }); - - describe('Jobs tab slot', () => { - it('shows job tab slot', () => { - const JOBS_TAB = '<div>Jobs Tab</div>'; - - createComponent({ - slots: { - 'jobs-tab': JOBS_TAB, - }, - }); - - expect(wrapper.html()).toContain(JOBS_TAB); - }); - }); }); diff --git a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js index b1b436e5443..83fb1764c6d 100644 --- a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js +++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js @@ -89,6 +89,16 @@ describe('RunnerList', () => { ]); }); + it('can be configured with null or undefined tokens, which are ignored', () => { + createComponent({ + props: { + tokens: [statusTokenConfig, null, undefined], + }, + }); + + expect(findFilteredSearch().props('tokens')).toEqual([statusTokenConfig]); + }); + it('fails validation for v-model with the wrong shape', () => { expect(() => { createComponent({ props: { value: { filters: 'wrong_filters', sort: 'sort' } } }); diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js index 872394430ae..eca4bbc3490 100644 --- a/spec/frontend/runner/components/runner_list_spec.js +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -7,9 +7,9 @@ import { import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerList from '~/runner/components/runner_list.vue'; import RunnerStatusPopover from '~/runner/components/runner_status_popover.vue'; -import { runnersData, onlineContactTimeoutSecs, staleTimeoutSecs } from '../mock_data'; +import { allRunnersData, onlineContactTimeoutSecs, staleTimeoutSecs } from '../mock_data'; -const mockRunners = runnersData.data.runners.nodes; +const mockRunners = allRunnersData.data.runners.nodes; const mockActiveRunnersCount = mockRunners.length; describe('RunnerList', () => { diff --git a/spec/frontend/runner/components/runner_pause_button_spec.js b/spec/frontend/runner/components/runner_pause_button_spec.js index 9ebb30b6ed7..61476007571 100644 --- a/spec/frontend/runner/components/runner_pause_button_spec.js +++ b/spec/frontend/runner/components/runner_pause_button_spec.js @@ -1,4 +1,4 @@ -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import { GlButton } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -16,9 +16,9 @@ import { } from '~/runner/constants'; import RunnerPauseButton from '~/runner/components/runner_pause_button.vue'; -import { runnersData } from '../mock_data'; +import { allRunnersData } from '../mock_data'; -const mockRunner = runnersData.data.runners.nodes[0]; +const mockRunner = allRunnersData.data.runners.nodes[0]; Vue.use(VueApollo); @@ -115,15 +115,20 @@ describe('RunnerPauseButton', () => { }); describe(`Immediately after the ${icon} button is clicked`, () => { - beforeEach(async () => { + const setup = async () => { findBtn().vm.$emit('click'); - }); + await nextTick(); + }; it('The button has a loading state', async () => { + await setup(); + expect(findBtn().props('loading')).toBe(true); }); it('The stale tooltip is removed', async () => { + await setup(); + expect(getTooltip()).toBe(''); }); }); @@ -237,15 +242,20 @@ describe('RunnerPauseButton', () => { }); describe('Immediately after the button is clicked', () => { - beforeEach(async () => { + const setup = async () => { findBtn().vm.$emit('click'); - }); + await nextTick(); + }; it('The button has a loading state', async () => { + await setup(); + expect(findBtn().props('loading')).toBe(true); }); it('The stale tooltip is removed', async () => { + await setup(); + expect(getTooltip()).toBe(''); }); }); diff --git a/spec/frontend/runner/components/runner_type_tabs_spec.js b/spec/frontend/runner/components/runner_type_tabs_spec.js index 9da5d842d8f..22d2a9e60f7 100644 --- a/spec/frontend/runner/components/runner_type_tabs_spec.js +++ b/spec/frontend/runner/components/runner_type_tabs_spec.js @@ -1,10 +1,30 @@ import { GlTab } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; +import RunnerCount from '~/runner/components/stat/runner_count.vue'; import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; const mockSearch = { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' }; +const mockCount = (type, multiplier = 1) => { + 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 count * multiplier; +}; + describe('RunnerTypeTabs', () => { let wrapper; @@ -13,33 +33,94 @@ describe('RunnerTypeTabs', () => { findTabs() .filter((tab) => tab.attributes('active') === 'true') .at(0); - const getTabsTitles = () => findTabs().wrappers.map((tab) => tab.text()); + const getTabsTitles = () => findTabs().wrappers.map((tab) => tab.text().replace(/\s+/g, ' ')); - const createComponent = ({ props, ...options } = {}) => { + const createComponent = ({ props, stubs, ...options } = {}) => { wrapper = shallowMount(RunnerTypeTabs, { propsData: { value: mockSearch, + countScope: INSTANCE_TYPE, + countVariables: {}, ...props, }, stubs: { GlTab, + ...stubs, }, ...options, }); }; - beforeEach(() => { - createComponent(); - }); - afterEach(() => { wrapper.destroy(); }); it('Renders all options to filter runners by default', () => { + createComponent(); + expect(getTabsTitles()).toEqual(['All', 'Instance', 'Group', 'Project']); }); + it('Shows count when receiving a number', () => { + createComponent({ + stubs: { + RunnerCount: { + props: ['variables'], + render() { + return this.$scopedSlots.default({ + count: mockCount(this.variables.type), + }); + }, + }, + }, + }); + + expect(getTabsTitles()).toEqual([`All 6`, `Instance 3`, `Group 2`, `Project 1`]); + }); + + it('Shows formatted count when receiving a large number', () => { + createComponent({ + stubs: { + RunnerCount: { + props: ['variables'], + render() { + return this.$scopedSlots.default({ + count: mockCount(this.variables.type, 1000), + }); + }, + }, + }, + }); + + expect(getTabsTitles()).toEqual([ + `All 6,000`, + `Instance 3,000`, + `Group 2,000`, + `Project 1,000`, + ]); + }); + + it('Renders a count next to each tab', () => { + const mockVariables = { + paused: true, + status: 'ONLINE', + }; + + createComponent({ + props: { + countVariables: mockVariables, + }, + }); + + findTabs().wrappers.forEach((tab) => { + expect(tab.find(RunnerCount).props()).toEqual({ + scope: INSTANCE_TYPE, + skip: false, + variables: expect.objectContaining(mockVariables), + }); + }); + }); + it('Renders fewer options to filter runners', () => { createComponent({ props: { @@ -51,6 +132,8 @@ describe('RunnerTypeTabs', () => { }); it('"All" is selected by default', () => { + createComponent(); + expect(findActiveTab().text()).toBe('All'); }); @@ -71,6 +154,7 @@ describe('RunnerTypeTabs', () => { const emittedValue = () => wrapper.emitted('input')[0][0]; beforeEach(() => { + createComponent(); findTabs().at(2).vm.$emit('click'); }); @@ -89,27 +173,30 @@ describe('RunnerTypeTabs', () => { }); }); - describe('When using a custom slot', () => { - const mockContent = 'content'; - - beforeEach(() => { - createComponent({ - scopedSlots: { - title: ` - <span> - {{props.tab.title}} ${mockContent} - </span>`, - }, + describe('Component API', () => { + describe('When .refetch() is called', () => { + let mockRefetch; + + beforeEach(() => { + mockRefetch = jest.fn(); + + createComponent({ + stubs: { + RunnerCount: { + methods: { + refetch: mockRefetch, + }, + render() {}, + }, + }, + }); + + wrapper.vm.refetch(); }); - }); - it('Renders tabs with additional information', () => { - expect(findTabs().wrappers.map((tab) => tab.text())).toEqual([ - `All ${mockContent}`, - `Instance ${mockContent}`, - `Group ${mockContent}`, - `Project ${mockContent}`, - ]); + it('refetch is called for each count', () => { + expect(mockRefetch).toHaveBeenCalledTimes(4); + }); }); }); }); 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 52557ff716d..22f0561ca5f 100644 --- a/spec/frontend/runner/components/search_tokens/tag_token_spec.js +++ b/spec/frontend/runner/components/search_tokens/tag_token_spec.js @@ -134,8 +134,6 @@ describe('TagToken', () => { describe('when the users filters suggestions', () => { beforeEach(async () => { findGlFilteredSearchToken().vm.$emit('input', { data: mockSearchTerm }); - - jest.runAllTimers(); }); it('requests filtered tags suggestions', async () => { @@ -145,6 +143,7 @@ describe('TagToken', () => { }); it('shows the loading icon', async () => { + findGlFilteredSearchToken().vm.$emit('input', { data: mockSearchTerm }); await nextTick(); expect(findGlLoadingIcon().exists()).toBe(true); diff --git a/spec/frontend/runner/components/stat/runner_count_spec.js b/spec/frontend/runner/components/stat/runner_count_spec.js new file mode 100644 index 00000000000..89b51b1b4a7 --- /dev/null +++ b/spec/frontend/runner/components/stat/runner_count_spec.js @@ -0,0 +1,148 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMount } from '@vue/test-utils'; +import RunnerCount from '~/runner/components/stat/runner_count.vue'; +import { INSTANCE_TYPE, GROUP_TYPE } from '~/runner/constants'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { captureException } from '~/runner/sentry_utils'; + +import allRunnersCountQuery from '~/runner/graphql/list/all_runners_count.query.graphql'; +import groupRunnersCountQuery from '~/runner/graphql/list/group_runners_count.query.graphql'; + +import { runnersCountData, groupRunnersCountData } from '../../mock_data'; + +jest.mock('~/runner/sentry_utils'); + +Vue.use(VueApollo); + +describe('RunnerCount', () => { + let wrapper; + let mockRunnersCountHandler; + let mockGroupRunnersCountHandler; + + const createComponent = ({ props = {}, ...options } = {}) => { + const handlers = [ + [allRunnersCountQuery, mockRunnersCountHandler], + [groupRunnersCountQuery, mockGroupRunnersCountHandler], + ]; + + wrapper = shallowMount(RunnerCount, { + apolloProvider: createMockApollo(handlers), + propsData: { + ...props, + }, + scopedSlots: { + default: '<strong>{{props.count}}</strong>', + }, + ...options, + }); + + return waitForPromises(); + }; + + beforeEach(() => { + mockRunnersCountHandler = jest.fn().mockResolvedValue(runnersCountData); + mockGroupRunnersCountHandler = jest.fn().mockResolvedValue(groupRunnersCountData); + }); + + describe('in admin scope', () => { + const mockVariables = { status: 'ONLINE' }; + + beforeEach(async () => { + await createComponent({ props: { scope: INSTANCE_TYPE } }); + }); + + it('fetches data from admin query', () => { + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(1); + expect(mockRunnersCountHandler).toHaveBeenCalledWith({}); + }); + + it('fetches data with filters', async () => { + await createComponent({ props: { scope: INSTANCE_TYPE, variables: mockVariables } }); + + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(2); + expect(mockRunnersCountHandler).toHaveBeenCalledWith(mockVariables); + + expect(wrapper.html()).toBe(`<strong>${runnersCountData.data.runners.count}</strong>`); + }); + + it('does not fetch from the group query', async () => { + expect(mockGroupRunnersCountHandler).not.toHaveBeenCalled(); + }); + + describe('when this query is skipped after data was loaded', () => { + beforeEach(async () => { + wrapper.setProps({ skip: true }); + + await nextTick(); + }); + + it('clears current data', () => { + expect(wrapper.html()).toBe('<strong></strong>'); + }); + }); + }); + + describe('when skipping query', () => { + beforeEach(async () => { + await createComponent({ props: { scope: INSTANCE_TYPE, skip: true } }); + }); + + it('does not fetch data', async () => { + expect(mockRunnersCountHandler).not.toHaveBeenCalled(); + expect(mockGroupRunnersCountHandler).not.toHaveBeenCalled(); + + expect(wrapper.html()).toBe('<strong></strong>'); + }); + }); + + describe('when runners query fails', () => { + const mockError = new Error('error!'); + + beforeEach(async () => { + mockRunnersCountHandler.mockRejectedValue(mockError); + + await createComponent({ props: { scope: INSTANCE_TYPE } }); + }); + + it('data is not shown and error is reported', async () => { + expect(wrapper.html()).toBe('<strong></strong>'); + + expect(captureException).toHaveBeenCalledWith({ + component: 'RunnerCount', + error: mockError, + }); + }); + }); + + describe('in group scope', () => { + beforeEach(async () => { + await createComponent({ props: { scope: GROUP_TYPE } }); + }); + + it('fetches data from the group query', async () => { + expect(mockGroupRunnersCountHandler).toHaveBeenCalledTimes(1); + expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({}); + + expect(wrapper.html()).toBe( + `<strong>${groupRunnersCountData.data.group.runners.count}</strong>`, + ); + }); + + it('does not fetch from the group query', () => { + expect(mockRunnersCountHandler).not.toHaveBeenCalled(); + }); + }); + + describe('when .refetch() is called', () => { + beforeEach(async () => { + await createComponent({ props: { scope: INSTANCE_TYPE } }); + wrapper.vm.refetch(); + }); + + it('data is not shown and error is reported', async () => { + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/spec/frontend/runner/components/stat/runner_stats_spec.js b/spec/frontend/runner/components/stat/runner_stats_spec.js index 68db8621ef0..f1ba6403dfb 100644 --- a/spec/frontend/runner/components/stat/runner_stats_spec.js +++ b/spec/frontend/runner/components/stat/runner_stats_spec.js @@ -1,21 +1,24 @@ import { shallowMount, mount } from '@vue/test-utils'; +import { s__ } from '~/locale'; import RunnerStats from '~/runner/components/stat/runner_stats.vue'; +import RunnerCount from '~/runner/components/stat/runner_count.vue'; import RunnerStatusStat from '~/runner/components/stat/runner_status_stat.vue'; -import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '~/runner/constants'; +import { INSTANCE_TYPE, STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '~/runner/constants'; describe('RunnerStats', () => { let wrapper; + const findRunnerCountAt = (i) => wrapper.findAllComponents(RunnerCount).at(i); const findRunnerStatusStatAt = (i) => wrapper.findAllComponents(RunnerStatusStat).at(i); - const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { + const createComponent = ({ props = {}, mountFn = shallowMount, ...options } = {}) => { wrapper = mountFn(RunnerStats, { propsData: { - onlineRunnersCount: 3, - offlineRunnersCount: 2, - staleRunnersCount: 1, + scope: INSTANCE_TYPE, + variables: {}, ...props, }, + ...options, }); }; @@ -24,13 +27,46 @@ describe('RunnerStats', () => { }); it('Displays all the stats', () => { - createComponent({ mountFn: mount }); + const mockCounts = { + [STATUS_ONLINE]: 3, + [STATUS_OFFLINE]: 2, + [STATUS_STALE]: 1, + }; + + createComponent({ + mountFn: mount, + stubs: { + RunnerCount: { + props: ['variables'], + render() { + return this.$scopedSlots.default({ + count: mockCounts[this.variables.status], + }); + }, + }, + }, + }); + + const text = wrapper.text(); + expect(text).toMatch(`${s__('Runners|Online runners')} 3`); + expect(text).toMatch(`${s__('Runners|Offline runners')} 2`); + expect(text).toMatch(`${s__('Runners|Stale runners')} 1`); + }); - const stats = wrapper.text(); + it('Displays counts for filtered searches', () => { + createComponent({ props: { variables: { paused: true } } }); - expect(stats).toMatch('Online runners 3'); - expect(stats).toMatch('Offline runners 2'); - expect(stats).toMatch('Stale runners 1'); + expect(findRunnerCountAt(0).props('variables').paused).toBe(true); + expect(findRunnerCountAt(1).props('variables').paused).toBe(true); + expect(findRunnerCountAt(2).props('variables').paused).toBe(true); + }); + + it('Skips overlapping statuses', () => { + createComponent({ props: { variables: { status: STATUS_ONLINE } } }); + + expect(findRunnerCountAt(0).props('skip')).toBe(false); + expect(findRunnerCountAt(1).props('skip')).toBe(true); + expect(findRunnerCountAt(2).props('skip')).toBe(true); }); it.each` @@ -38,9 +74,10 @@ describe('RunnerStats', () => { ${0} | ${STATUS_ONLINE} ${1} | ${STATUS_OFFLINE} ${2} | ${STATUS_STALE} - `('Displays status types at index $i', ({ i, status }) => { - createComponent(); + `('Displays status $status at index $i', ({ i, status }) => { + createComponent({ mountFn: mount }); + expect(findRunnerCountAt(i).props('variables').status).toBe(status); expect(findRunnerStatusStatAt(i).props('status')).toBe(status); }); }); diff --git a/spec/frontend/runner/group_runner_show/group_runner_show_app_spec.js b/spec/frontend/runner/group_runner_show/group_runner_show_app_spec.js new file mode 100644 index 00000000000..2065874c288 --- /dev/null +++ b/spec/frontend/runner/group_runner_show/group_runner_show_app_spec.js @@ -0,0 +1,213 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; +import { redirectTo } from '~/lib/utils/url_utility'; + +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import RunnerHeader from '~/runner/components/runner_header.vue'; +import RunnerDetails from '~/runner/components/runner_details.vue'; +import RunnerPauseButton from '~/runner/components/runner_pause_button.vue'; +import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue'; +import RunnerEditButton from '~/runner/components/runner_edit_button.vue'; +import runnerQuery from '~/runner/graphql/show/runner.query.graphql'; +import GroupRunnerShowApp from '~/runner/group_runner_show/group_runner_show_app.vue'; +import { captureException } from '~/runner/sentry_utils'; +import { saveAlertToLocalStorage } from '~/runner/local_storage_alert/save_alert_to_local_storage'; + +import { runnerData } from '../mock_data'; + +jest.mock('~/runner/local_storage_alert/save_alert_to_local_storage'); +jest.mock('~/flash'); +jest.mock('~/runner/sentry_utils'); +jest.mock('~/lib/utils/url_utility'); + +const mockRunner = runnerData.data.runner; +const mockRunnerGraphqlId = mockRunner.id; +const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`; +const mockRunnersPath = '/groups/group1/-/runners'; +const mockEditGroupRunnerPath = `/groups/group1/-/runners/${mockRunnerId}/edit`; + +Vue.use(VueApollo); + +describe('GroupRunnerShowApp', () => { + let wrapper; + let mockRunnerQuery; + + const findRunnerHeader = () => wrapper.findComponent(RunnerHeader); + const findRunnerDetails = () => wrapper.findComponent(RunnerDetails); + const findRunnerDeleteButton = () => wrapper.findComponent(RunnerDeleteButton); + const findRunnerEditButton = () => wrapper.findComponent(RunnerEditButton); + const findRunnerPauseButton = () => wrapper.findComponent(RunnerPauseButton); + + const mockRunnerQueryResult = (runner = {}) => { + mockRunnerQuery = jest.fn().mockResolvedValue({ + data: { + runner: { ...mockRunner, ...runner }, + }, + }); + }; + + const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => { + wrapper = mountFn(GroupRunnerShowApp, { + apolloProvider: createMockApollo([[runnerQuery, mockRunnerQuery]]), + propsData: { + runnerId: mockRunnerId, + runnersPath: mockRunnersPath, + editGroupRunnerPath: mockEditGroupRunnerPath, + ...props, + }, + ...options, + }); + + return waitForPromises(); + }; + + afterEach(() => { + mockRunnerQuery.mockReset(); + wrapper.destroy(); + }); + + describe('When showing runner details', () => { + beforeEach(async () => { + mockRunnerQueryResult(); + + await createComponent({ mountFn: mountExtended }); + }); + + it('expect GraphQL ID to be requested', async () => { + expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunnerGraphqlId }); + }); + + it('displays the header', async () => { + expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`); + }); + + it('displays edit, pause, delete buttons', async () => { + expect(findRunnerEditButton().exists()).toBe(true); + expect(findRunnerPauseButton().exists()).toBe(true); + expect(findRunnerDeleteButton().exists()).toBe(true); + }); + + it('shows basic runner details', () => { + const expected = `Description Instance runner + Last contact Never contacted + Version 1.0.0 + IP Address 127.0.0.1 + Executor None + Architecture None + Platform darwin + Configuration Runs untagged jobs + Maximum job timeout None + Tags None`.replace(/\s+/g, ' '); + + expect(wrapper.text().replace(/\s+/g, ' ')).toContain(expected); + }); + + it('renders runner details component', () => { + expect(findRunnerDetails().props('runner')).toEqual(mockRunner); + }); + + describe('when runner cannot be updated', () => { + beforeEach(async () => { + mockRunnerQueryResult({ + userPermissions: { + ...mockRunner.userPermissions, + updateRunner: false, + }, + }); + + await createComponent({ + mountFn: mountExtended, + }); + }); + + it('does not display edit and pause buttons', () => { + expect(findRunnerEditButton().exists()).toBe(false); + expect(findRunnerPauseButton().exists()).toBe(false); + }); + + it('displays delete button', () => { + expect(findRunnerDeleteButton().exists()).toBe(true); + }); + }); + + describe('when runner cannot be deleted', () => { + beforeEach(async () => { + mockRunnerQueryResult({ + userPermissions: { + ...mockRunner.userPermissions, + deleteRunner: false, + }, + }); + + await createComponent({ + mountFn: mountExtended, + }); + }); + + it('does not display delete button', () => { + expect(findRunnerDeleteButton().exists()).toBe(false); + }); + + it('displays edit and pause buttons', () => { + expect(findRunnerEditButton().exists()).toBe(true); + expect(findRunnerPauseButton().exists()).toBe(true); + }); + }); + + describe('when runner is deleted', () => { + beforeEach(async () => { + await createComponent({ + mountFn: mountExtended, + }); + }); + + it('redirects to the runner list page', () => { + findRunnerDeleteButton().vm.$emit('deleted', { message: 'Runner deleted' }); + + expect(saveAlertToLocalStorage).toHaveBeenCalledWith({ + message: 'Runner deleted', + variant: VARIANT_SUCCESS, + }); + expect(redirectTo).toHaveBeenCalledWith(mockRunnersPath); + }); + }); + }); + + describe('When loading', () => { + beforeEach(() => { + mockRunnerQueryResult(); + + createComponent(); + }); + + it('does not show runner details', () => { + expect(findRunnerDetails().exists()).toBe(false); + }); + }); + + describe('When there is an error', () => { + beforeEach(async () => { + mockRunnerQuery = jest.fn().mockRejectedValueOnce(new Error('Error!')); + await createComponent(); + }); + + it('does not show runner details', () => { + expect(findRunnerDetails().exists()).toBe(false); + }); + + it('error is reported to sentry', () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error('Error!'), + component: 'GroupRunnerShowApp', + }); + }); + + it('error is shown to the user', () => { + expect(createAlert).toHaveBeenCalled(); + }); + }); +}); 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 eb9f85a7d0f..9c42b0d6865 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -10,6 +10,7 @@ import { } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; +import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory } from '~/lib/utils/url_utility'; @@ -18,6 +19,7 @@ import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_ import RunnerList from '~/runner/components/runner_list.vue'; import RunnerListEmptyState from '~/runner/components/runner_list_empty_state.vue'; import RunnerStats from '~/runner/components/stat/runner_stats.vue'; +import RunnerCount from '~/runner/components/stat/runner_count.vue'; import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue'; import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; import RunnerPagination from '~/runner/components/runner_pagination.vue'; @@ -28,7 +30,6 @@ import { DEFAULT_SORT, INSTANCE_TYPE, GROUP_TYPE, - PROJECT_TYPE, PARAM_KEY_PAUSED, PARAM_KEY_STATUS, PARAM_KEY_TAG, @@ -38,11 +39,10 @@ import { RUNNER_PAGE_SIZE, I18N_EDIT, } from '~/runner/constants'; -import getGroupRunnersQuery from '~/runner/graphql/list/group_runners.query.graphql'; -import getGroupRunnersCountQuery from '~/runner/graphql/list/group_runners_count.query.graphql'; +import groupRunnersQuery from '~/runner/graphql/list/group_runners.query.graphql'; +import groupRunnersCountQuery from '~/runner/graphql/list/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, @@ -61,6 +61,9 @@ const mockRegistrationToken = 'AABBCC'; const mockGroupRunnersEdges = groupRunnersData.data.group.runners.edges; const mockGroupRunnersCount = mockGroupRunnersEdges.length; +const mockGroupRunnersHandler = jest.fn(); +const mockGroupRunnersCountHandler = jest.fn(); + jest.mock('~/flash'); jest.mock('~/runner/sentry_utils'); jest.mock('~/lib/utils/url_utility', () => ({ @@ -70,8 +73,6 @@ jest.mock('~/lib/utils/url_utility', () => ({ describe('GroupRunnersApp', () => { let wrapper; - let mockGroupRunnersQuery; - let mockGroupRunnersCountQuery; const findRunnerStats = () => wrapper.findComponent(RunnerStats); const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell); @@ -83,17 +84,11 @@ describe('GroupRunnersApp', () => { const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page'); const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); - const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); - - const mockCountQueryResult = (count) => - Promise.resolve({ - data: { group: { id: groupRunnersCountData.data.group.id, runners: { count } } }, - }); - const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => { + const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => { const handlers = [ - [getGroupRunnersQuery, mockGroupRunnersQuery], - [getGroupRunnersCountQuery, mockGroupRunnersCountQuery], + [groupRunnersQuery, mockGroupRunnersHandler], + [groupRunnersCountQuery, mockGroupRunnersCountHandler], ]; wrapper = mountFn(GroupRunnersApp, { @@ -110,90 +105,76 @@ describe('GroupRunnersApp', () => { emptyStateSvgPath, emptyStateFilteredSvgPath, }, + ...options, }); + + return waitForPromises(); }; beforeEach(async () => { - setWindowLocation(`/groups/${mockGroupFullPath}/-/runners`); + mockGroupRunnersHandler.mockResolvedValue(groupRunnersData); + mockGroupRunnersCountHandler.mockResolvedValue(groupRunnersCountData); + }); - mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersData); - mockGroupRunnersCountQuery = jest.fn().mockResolvedValue(groupRunnersCountData); + afterEach(() => { + mockGroupRunnersHandler.mockReset(); + mockGroupRunnersCountHandler.mockReset(); + wrapper.destroy(); + }); + + it('shows the runner tabs with a runner count for each type', async () => { + await createComponent({ mountFn: mountExtended }); + expect(findRunnerTypeTabs().text()).toMatchInterpolatedText( + `All ${mockGroupRunnersCount} Group ${mockGroupRunnersCount} Project ${mockGroupRunnersCount}`, + ); + }); + + it('shows the runner setup instructions', () => { createComponent(); - await waitForPromises(); + + expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken); + expect(findRegistrationDropdown().props('type')).toBe(GROUP_TYPE); }); it('shows total runner counts', async () => { - expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({ - groupFullPath: mockGroupFullPath, + await createComponent({ mountFn: mountExtended }); + + expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ status: STATUS_ONLINE, - }); - expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({ groupFullPath: mockGroupFullPath, - status: STATUS_OFFLINE, }); - expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({ + expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ + status: STATUS_OFFLINE, groupFullPath: mockGroupFullPath, - status: STATUS_STALE, - }); - - expect(findRunnerStats().props()).toMatchObject({ - onlineRunnersCount: mockGroupRunnersCount, - offlineRunnersCount: mockGroupRunnersCount, - staleRunnersCount: mockGroupRunnersCount, - }); - }); - - it('shows the runner tabs with a runner count for each type', async () => { - mockGroupRunnersCountQuery.mockImplementation(({ type }) => { - switch (type) { - case GROUP_TYPE: - return mockCountQueryResult(2); - case PROJECT_TYPE: - return mockCountQueryResult(1); - default: - return mockCountQueryResult(4); - } }); - - createComponent({ mountFn: mountExtended }); - await waitForPromises(); - - expect(findRunnerTypeTabs().text()).toMatchInterpolatedText('All 4 Group 2 Project 1'); - }); - - it('shows the runner tabs with a formatted runner count', async () => { - mockGroupRunnersCountQuery.mockImplementation(({ type }) => { - switch (type) { - case GROUP_TYPE: - return mockCountQueryResult(2000); - case PROJECT_TYPE: - return mockCountQueryResult(1000); - default: - return mockCountQueryResult(3000); - } + expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ + status: STATUS_STALE, + groupFullPath: mockGroupFullPath, }); - createComponent({ mountFn: mountExtended }); - await waitForPromises(); - - expect(findRunnerTypeTabs().text()).toMatchInterpolatedText( - 'All 3,000 Group 2,000 Project 1,000', + expect(findRunnerStats().text()).toContain( + `${s__('Runners|Online runners')} ${mockGroupRunnersCount}`, + ); + expect(findRunnerStats().text()).toContain( + `${s__('Runners|Offline runners')} ${mockGroupRunnersCount}`, + ); + expect(findRunnerStats().text()).toContain( + `${s__('Runners|Stale runners')} ${mockGroupRunnersCount}`, ); }); - it('shows the runner setup instructions', () => { - expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken); - expect(findRegistrationDropdown().props('type')).toBe(GROUP_TYPE); - }); + it('shows the runners list', async () => { + await createComponent(); - it('shows the runners list', () => { const runners = findRunnerList().props('runners'); expect(runners).toEqual(mockGroupRunnersEdges.map(({ node }) => node)); }); - it('requests the runners with group path and no other filters', () => { - expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ + it('requests the runners with group path and no other filters', async () => { + await createComponent(); + + expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({ groupFullPath: mockGroupFullPath, status: undefined, type: undefined, @@ -203,9 +184,9 @@ describe('GroupRunnersApp', () => { }); it('sets tokens in the filtered search', () => { - createComponent({ mountFn: mountExtended }); + createComponent(); - const tokens = findFilteredSearch().props('tokens'); + const tokens = findRunnerFilteredSearchBar().props('tokens'); expect(tokens).toEqual([ expect.objectContaining({ @@ -229,12 +210,8 @@ describe('GroupRunnersApp', () => { const FILTERED_COUNT_QUERIES = 3; // Smart queries that display a count of runners in tabs beforeEach(async () => { - mockGroupRunnersCountQuery.mockClear(); - - createComponent({ mountFn: mountExtended }); + await createComponent({ mountFn: mountExtended }); showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show'); - - await waitForPromises(); }); it('view link is displayed correctly', () => { @@ -254,11 +231,11 @@ describe('GroupRunnersApp', () => { }); it('When runner is paused or unpaused, some data is refetched', async () => { - expect(mockGroupRunnersCountQuery).toHaveBeenCalledTimes(COUNT_QUERIES); + expect(mockGroupRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); findRunnerActionsCell().vm.$emit('toggledPaused'); - expect(mockGroupRunnersCountQuery).toHaveBeenCalledTimes( + expect(mockGroupRunnersCountHandler).toHaveBeenCalledTimes( COUNT_QUERIES + FILTERED_COUNT_QUERIES, ); @@ -277,8 +254,12 @@ describe('GroupRunnersApp', () => { beforeEach(async () => { setWindowLocation(`?status[]=${STATUS_ONLINE}&runner_type[]=${INSTANCE_TYPE}`); - createComponent(); - await waitForPromises(); + await createComponent({ + stubs: { + RunnerStats, + RunnerCount, + }, + }); }); it('sets the filters in the search bar', () => { @@ -291,7 +272,7 @@ describe('GroupRunnersApp', () => { }); it('requests the runners with filter parameters', () => { - expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ + expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({ groupFullPath: mockGroupFullPath, status: STATUS_ONLINE, type: INSTANCE_TYPE, @@ -301,20 +282,23 @@ describe('GroupRunnersApp', () => { }); it('fetches count results for requested status', () => { - expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({ + expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ groupFullPath: mockGroupFullPath, type: INSTANCE_TYPE, status: STATUS_ONLINE, }); - - expect(findRunnerStats().props()).toMatchObject({ - onlineRunnersCount: mockGroupRunnersCount, - }); }); }); describe('when a filter is selected by the user', () => { beforeEach(async () => { + createComponent({ + stubs: { + RunnerStats, + RunnerCount, + }, + }); + findRunnerFilteredSearchBar().vm.$emit('input', { runnerType: null, filters: [ @@ -330,12 +314,12 @@ describe('GroupRunnersApp', () => { it('updates the browser url', () => { expect(updateHistory).toHaveBeenLastCalledWith({ title: expect.any(String), - url: 'http://test.host/groups/group1/-/runners?status[]=ONLINE&tag[]=tag1&sort=CREATED_ASC', + url: expect.stringContaining('?status[]=ONLINE&tag[]=tag1&sort=CREATED_ASC'), }); }); it('requests the runners with filters', () => { - expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ + expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({ groupFullPath: mockGroupFullPath, status: STATUS_ONLINE, tagList: ['tag1'], @@ -345,33 +329,11 @@ describe('GroupRunnersApp', () => { }); it('fetches count results for requested status', () => { - expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({ + expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ groupFullPath: mockGroupFullPath, tagList: ['tag1'], status: STATUS_ONLINE, }); - - expect(findRunnerStats().props()).toMatchObject({ - onlineRunnersCount: mockGroupRunnersCount, - }); - }); - - it('skips fetching count results for status that were not in filter', () => { - expect(mockGroupRunnersCountQuery).not.toHaveBeenCalledWith({ - groupFullPath: mockGroupFullPath, - tagList: ['tag1'], - status: STATUS_OFFLINE, - }); - expect(mockGroupRunnersCountQuery).not.toHaveBeenCalledWith({ - groupFullPath: mockGroupFullPath, - tagList: ['tag1'], - status: STATUS_STALE, - }); - - expect(findRunnerStats().props()).toMatchObject({ - offlineRunnersCount: null, - staleRunnersCount: null, - }); }); }); @@ -382,7 +344,7 @@ describe('GroupRunnersApp', () => { describe('when no runners are found', () => { beforeEach(async () => { - mockGroupRunnersQuery = jest.fn().mockResolvedValue({ + mockGroupRunnersHandler.mockResolvedValue({ data: { group: { id: '1', @@ -390,8 +352,7 @@ describe('GroupRunnersApp', () => { }, }, }); - createComponent(); - await waitForPromises(); + await createComponent(); }); it('shows an empty state', async () => { @@ -401,9 +362,8 @@ describe('GroupRunnersApp', () => { describe('when runners query fails', () => { beforeEach(async () => { - mockGroupRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!')); - createComponent(); - await waitForPromises(); + mockGroupRunnersHandler.mockRejectedValue(new Error('Error!')); + await createComponent(); }); it('error is shown to the user', async () => { @@ -420,16 +380,15 @@ describe('GroupRunnersApp', () => { describe('Pagination', () => { beforeEach(async () => { - mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersDataPaginated); + mockGroupRunnersHandler.mockResolvedValue(groupRunnersDataPaginated); - createComponent({ mountFn: mountExtended }); - await waitForPromises(); + await createComponent({ mountFn: mountExtended }); }); it('navigates to the next page', async () => { await findRunnerPaginationNext().trigger('click'); - expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ + expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({ groupFullPath: mockGroupFullPath, sort: CREATED_DESC, first: RUNNER_PAGE_SIZE, diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js index 3368fc21544..e5472ace817 100644 --- a/spec/frontend/runner/mock_data.js +++ b/spec/frontend/runner/mock_data.js @@ -10,14 +10,216 @@ import runnerJobsData from 'test_fixtures/graphql/runner/show/runner_jobs.query. import runnerFormData from 'test_fixtures/graphql/runner/edit/runner_form.query.graphql.json'; // List queries -import runnersData from 'test_fixtures/graphql/runner/list/admin_runners.query.graphql.json'; -import runnersDataPaginated from 'test_fixtures/graphql/runner/list/admin_runners.query.graphql.paginated.json'; -import runnersCountData from 'test_fixtures/graphql/runner/list/admin_runners_count.query.graphql.json'; +import allRunnersData from 'test_fixtures/graphql/runner/list/all_runners.query.graphql.json'; +import allRunnersDataPaginated from 'test_fixtures/graphql/runner/list/all_runners.query.graphql.paginated.json'; +import runnersCountData from 'test_fixtures/graphql/runner/list/all_runners_count.query.graphql.json'; import groupRunnersData from 'test_fixtures/graphql/runner/list/group_runners.query.graphql.json'; import groupRunnersDataPaginated from 'test_fixtures/graphql/runner/list/group_runners.query.graphql.paginated.json'; import groupRunnersCountData from 'test_fixtures/graphql/runner/list/group_runners_count.query.graphql.json'; +import { RUNNER_PAGE_SIZE } from '~/runner/constants'; + // Other mock data + +// Mock searches and their corresponding urls +export const mockSearchExamples = [ + { + name: 'a default query', + urlQuery: '', + search: { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' }, + graphqlVariables: { sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + isDefault: true, + }, + { + name: 'a single status', + urlQuery: '?status[]=ACTIVE', + search: { + runnerType: null, + filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'a single term text search', + urlQuery: '?search=something', + search: { + runnerType: null, + filters: [ + { + type: 'filtered-search-term', + value: { data: 'something' }, + }, + ], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { search: 'something', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'a two terms text search', + urlQuery: '?search=something+else', + search: { + runnerType: null, + filters: [ + { + type: 'filtered-search-term', + value: { data: 'something' }, + }, + { + type: 'filtered-search-term', + value: { data: 'else' }, + }, + ], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { search: 'something else', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'single instance type', + urlQuery: '?runner_type[]=INSTANCE_TYPE', + search: { + runnerType: 'INSTANCE_TYPE', + filters: [], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { type: 'INSTANCE_TYPE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'multiple runner status', + urlQuery: '?status[]=ACTIVE&status[]=PAUSED', + search: { + runnerType: null, + filters: [ + { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, + { type: 'status', value: { data: 'PAUSED', operator: '=' } }, + ], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'multiple status, a single instance type and a non default sort', + urlQuery: '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC', + search: { + runnerType: 'INSTANCE_TYPE', + filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }], + pagination: { page: 1 }, + sort: 'CREATED_ASC', + }, + graphqlVariables: { + status: 'ACTIVE', + type: 'INSTANCE_TYPE', + sort: 'CREATED_ASC', + first: RUNNER_PAGE_SIZE, + }, + }, + { + name: 'a tag', + urlQuery: '?tag[]=tag-1', + search: { + runnerType: null, + filters: [{ type: 'tag', value: { data: 'tag-1', operator: '=' } }], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { + tagList: ['tag-1'], + first: 20, + sort: 'CREATED_DESC', + }, + }, + { + name: 'two tags', + urlQuery: '?tag[]=tag-1&tag[]=tag-2', + search: { + runnerType: null, + filters: [ + { type: 'tag', value: { data: 'tag-1', operator: '=' } }, + { type: 'tag', value: { data: 'tag-2', operator: '=' } }, + ], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { + tagList: ['tag-1', 'tag-2'], + first: 20, + sort: 'CREATED_DESC', + }, + }, + { + name: 'the next page', + urlQuery: '?page=2&after=AFTER_CURSOR', + search: { + runnerType: null, + filters: [], + pagination: { page: 2, after: 'AFTER_CURSOR' }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { sort: 'CREATED_DESC', after: 'AFTER_CURSOR', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'the previous page', + urlQuery: '?page=2&before=BEFORE_CURSOR', + search: { + runnerType: null, + filters: [], + pagination: { page: 2, before: 'BEFORE_CURSOR' }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { sort: 'CREATED_DESC', before: 'BEFORE_CURSOR', last: RUNNER_PAGE_SIZE }, + }, + { + name: 'the next page filtered by a status, an instance type, tags and a non default sort', + urlQuery: + '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&tag[]=tag-1&tag[]=tag-2&sort=CREATED_ASC&page=2&after=AFTER_CURSOR', + search: { + runnerType: 'INSTANCE_TYPE', + filters: [ + { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, + { type: 'tag', value: { data: 'tag-1', operator: '=' } }, + { type: 'tag', value: { data: 'tag-2', operator: '=' } }, + ], + pagination: { page: 2, after: 'AFTER_CURSOR' }, + sort: 'CREATED_ASC', + }, + graphqlVariables: { + status: 'ACTIVE', + type: 'INSTANCE_TYPE', + tagList: ['tag-1', 'tag-2'], + sort: 'CREATED_ASC', + after: 'AFTER_CURSOR', + first: RUNNER_PAGE_SIZE, + }, + }, + { + name: 'paused runners', + urlQuery: '?paused[]=true', + search: { + runnerType: null, + filters: [{ type: 'paused', value: { data: 'true', operator: '=' } }], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { paused: true, sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'active runners', + urlQuery: '?paused[]=false', + search: { + runnerType: null, + filters: [{ type: 'paused', value: { data: 'false', operator: '=' } }], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { paused: false, sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, +]; + export const onlineContactTimeoutSecs = 2 * 60 * 60; export const staleTimeoutSecs = 7889238; // Ruby's `3.months` @@ -25,8 +227,8 @@ export const emptyStateSvgPath = 'emptyStateSvgPath.svg'; export const emptyStateFilteredSvgPath = 'emptyStateFilteredSvgPath.svg'; export { - runnersData, - runnersDataPaginated, + allRunnersData, + allRunnersDataPaginated, runnersCountData, groupRunnersData, groupRunnersDataPaginated, diff --git a/spec/frontend/runner/runner_search_utils_spec.js b/spec/frontend/runner/runner_search_utils_spec.js index 1f102f86b2a..6f954143ab1 100644 --- a/spec/frontend/runner/runner_search_utils_spec.js +++ b/spec/frontend/runner/runner_search_utils_spec.js @@ -1,4 +1,3 @@ -import { RUNNER_PAGE_SIZE } from '~/runner/constants'; import { searchValidator, updateOutdatedUrl, @@ -6,209 +5,12 @@ import { fromSearchToUrl, fromSearchToVariables, isSearchFiltered, -} from '~/runner/runner_search_utils'; +} from 'ee_else_ce/runner/runner_search_utils'; +import { mockSearchExamples } from './mock_data'; describe('search_params.js', () => { - const examples = [ - { - name: 'a default query', - urlQuery: '', - search: { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' }, - graphqlVariables: { sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, - isDefault: true, - }, - { - name: 'a single status', - urlQuery: '?status[]=ACTIVE', - search: { - runnerType: null, - filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }], - pagination: { page: 1 }, - sort: 'CREATED_DESC', - }, - graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, - }, - { - name: 'a single term text search', - urlQuery: '?search=something', - search: { - runnerType: null, - filters: [ - { - type: 'filtered-search-term', - value: { data: 'something' }, - }, - ], - pagination: { page: 1 }, - sort: 'CREATED_DESC', - }, - graphqlVariables: { search: 'something', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, - }, - { - name: 'a two terms text search', - urlQuery: '?search=something+else', - search: { - runnerType: null, - filters: [ - { - type: 'filtered-search-term', - value: { data: 'something' }, - }, - { - type: 'filtered-search-term', - value: { data: 'else' }, - }, - ], - pagination: { page: 1 }, - sort: 'CREATED_DESC', - }, - graphqlVariables: { search: 'something else', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, - }, - { - name: 'single instance type', - urlQuery: '?runner_type[]=INSTANCE_TYPE', - search: { - runnerType: 'INSTANCE_TYPE', - filters: [], - pagination: { page: 1 }, - sort: 'CREATED_DESC', - }, - graphqlVariables: { type: 'INSTANCE_TYPE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, - }, - { - name: 'multiple runner status', - urlQuery: '?status[]=ACTIVE&status[]=PAUSED', - search: { - runnerType: null, - filters: [ - { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, - { type: 'status', value: { data: 'PAUSED', operator: '=' } }, - ], - pagination: { page: 1 }, - sort: 'CREATED_DESC', - }, - graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, - }, - { - name: 'multiple status, a single instance type and a non default sort', - urlQuery: '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC', - search: { - runnerType: 'INSTANCE_TYPE', - filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }], - pagination: { page: 1 }, - sort: 'CREATED_ASC', - }, - graphqlVariables: { - status: 'ACTIVE', - type: 'INSTANCE_TYPE', - sort: 'CREATED_ASC', - first: RUNNER_PAGE_SIZE, - }, - }, - { - name: 'a tag', - urlQuery: '?tag[]=tag-1', - search: { - runnerType: null, - filters: [{ type: 'tag', value: { data: 'tag-1', operator: '=' } }], - pagination: { page: 1 }, - sort: 'CREATED_DESC', - }, - graphqlVariables: { - tagList: ['tag-1'], - first: 20, - sort: 'CREATED_DESC', - }, - }, - { - name: 'two tags', - urlQuery: '?tag[]=tag-1&tag[]=tag-2', - search: { - runnerType: null, - filters: [ - { type: 'tag', value: { data: 'tag-1', operator: '=' } }, - { type: 'tag', value: { data: 'tag-2', operator: '=' } }, - ], - pagination: { page: 1 }, - sort: 'CREATED_DESC', - }, - graphqlVariables: { - tagList: ['tag-1', 'tag-2'], - first: 20, - sort: 'CREATED_DESC', - }, - }, - { - name: 'the next page', - urlQuery: '?page=2&after=AFTER_CURSOR', - search: { - runnerType: null, - filters: [], - pagination: { page: 2, after: 'AFTER_CURSOR' }, - sort: 'CREATED_DESC', - }, - graphqlVariables: { sort: 'CREATED_DESC', after: 'AFTER_CURSOR', first: RUNNER_PAGE_SIZE }, - }, - { - name: 'the previous page', - urlQuery: '?page=2&before=BEFORE_CURSOR', - search: { - runnerType: null, - filters: [], - pagination: { page: 2, before: 'BEFORE_CURSOR' }, - sort: 'CREATED_DESC', - }, - graphqlVariables: { sort: 'CREATED_DESC', before: 'BEFORE_CURSOR', last: RUNNER_PAGE_SIZE }, - }, - { - name: 'the next page filtered by a status, an instance type, tags and a non default sort', - urlQuery: - '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&tag[]=tag-1&tag[]=tag-2&sort=CREATED_ASC&page=2&after=AFTER_CURSOR', - search: { - runnerType: 'INSTANCE_TYPE', - filters: [ - { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, - { type: 'tag', value: { data: 'tag-1', operator: '=' } }, - { type: 'tag', value: { data: 'tag-2', operator: '=' } }, - ], - pagination: { page: 2, after: 'AFTER_CURSOR' }, - sort: 'CREATED_ASC', - }, - graphqlVariables: { - status: 'ACTIVE', - type: 'INSTANCE_TYPE', - tagList: ['tag-1', 'tag-2'], - sort: 'CREATED_ASC', - after: 'AFTER_CURSOR', - first: RUNNER_PAGE_SIZE, - }, - }, - { - name: 'paused runners', - urlQuery: '?paused[]=true', - search: { - runnerType: null, - filters: [{ type: 'paused', value: { data: 'true', operator: '=' } }], - pagination: { page: 1 }, - sort: 'CREATED_DESC', - }, - graphqlVariables: { paused: true, sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, - }, - { - name: 'active runners', - urlQuery: '?paused[]=false', - search: { - runnerType: null, - filters: [{ type: 'paused', value: { data: 'false', operator: '=' } }], - pagination: { page: 1 }, - sort: 'CREATED_DESC', - }, - graphqlVariables: { paused: false, sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, - }, - ]; - describe('searchValidator', () => { - examples.forEach(({ name, search }) => { + mockSearchExamples.forEach(({ name, search }) => { it(`Validates ${name} as a search object`, () => { expect(searchValidator(search)).toBe(true); }); @@ -235,7 +37,7 @@ describe('search_params.js', () => { }); describe('fromUrlQueryToSearch', () => { - examples.forEach(({ name, urlQuery, search }) => { + mockSearchExamples.forEach(({ name, urlQuery, search }) => { it(`Converts ${name} to a search object`, () => { expect(fromUrlQueryToSearch(urlQuery)).toEqual(search); }); @@ -268,7 +70,7 @@ describe('search_params.js', () => { }); describe('fromSearchToUrl', () => { - examples.forEach(({ name, urlQuery, search }) => { + mockSearchExamples.forEach(({ name, urlQuery, search }) => { it(`Converts ${name} to a url`, () => { expect(fromSearchToUrl(search)).toBe(`http://test.host/${urlQuery}`); }); @@ -295,7 +97,7 @@ describe('search_params.js', () => { }); describe('fromSearchToVariables', () => { - examples.forEach(({ name, graphqlVariables, search }) => { + mockSearchExamples.forEach(({ name, graphqlVariables, search }) => { it(`Converts ${name} to a GraphQL query variables object`, () => { expect(fromSearchToVariables(search)).toEqual(graphqlVariables); }); @@ -335,7 +137,7 @@ describe('search_params.js', () => { }); describe('isSearchFiltered', () => { - examples.forEach(({ name, search, isDefault }) => { + mockSearchExamples.forEach(({ name, search, isDefault }) => { it(`Given ${name}, evaluates to ${isDefault ? 'not ' : ''}filtered`, () => { expect(isSearchFiltered(search)).toBe(!isDefault); }); diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js index de91e51924d..222cabc6a63 100644 --- a/spec/frontend/security_configuration/components/app_spec.js +++ b/spec/frontend/security_configuration/components/app_spec.js @@ -1,11 +1,10 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { GlTab, GlTabs, GlLink } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser'; import stubChildren from 'helpers/stub_children'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import SecurityConfigurationApp, { i18n } from '~/security_configuration/components/app.vue'; import AutoDevopsAlert from '~/security_configuration/components/auto_dev_ops_alert.vue'; import AutoDevopsEnabledAlert from '~/security_configuration/components/auto_dev_ops_enabled_alert.vue'; @@ -42,35 +41,57 @@ describe('App component', () => { let wrapper; let userCalloutDismissSpy; - const createComponent = ({ shouldShowCallout = true, ...propsData }) => { + const securityFeaturesMock = [ + { + name: SAST_NAME, + shortName: SAST_SHORT_NAME, + description: SAST_DESCRIPTION, + helpPath: SAST_HELP_PATH, + configurationHelpPath: SAST_CONFIG_HELP_PATH, + type: REPORT_TYPE_SAST, + available: true, + }, + ]; + + const complianceFeaturesMock = [ + { + name: LICENSE_COMPLIANCE_NAME, + description: LICENSE_COMPLIANCE_DESCRIPTION, + helpPath: LICENSE_COMPLIANCE_HELP_PATH, + type: REPORT_TYPE_LICENSE_COMPLIANCE, + configurationHelpPath: LICENSE_COMPLIANCE_HELP_PATH, + }, + ]; + + const createComponent = ({ shouldShowCallout = true, ...propsData } = {}) => { userCalloutDismissSpy = jest.fn(); - wrapper = extendedWrapper( - mount(SecurityConfigurationApp, { - propsData: { - securityTrainingEnabled: true, - ...propsData, - }, - provide: { - upgradePath, - autoDevopsHelpPagePath, - autoDevopsPath, - projectFullPath, - vulnerabilityTrainingDocsPath, - }, - stubs: { - ...stubChildren(SecurityConfigurationApp), - GlLink: false, - GlSprintf: false, - LocalStorageSync: false, - SectionLayout: false, - UserCalloutDismisser: makeMockUserCalloutDismisser({ - dismiss: userCalloutDismissSpy, - shouldShowCallout, - }), - }, - }), - ); + wrapper = mountExtended(SecurityConfigurationApp, { + propsData: { + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock, + securityTrainingEnabled: true, + ...propsData, + }, + provide: { + upgradePath, + autoDevopsHelpPagePath, + autoDevopsPath, + projectFullPath, + vulnerabilityTrainingDocsPath, + }, + stubs: { + ...stubChildren(SecurityConfigurationApp), + GlLink: false, + GlSprintf: false, + LocalStorageSync: false, + SectionLayout: false, + UserCalloutDismisser: makeMockUserCalloutDismisser({ + dismiss: userCalloutDismissSpy, + shouldShowCallout, + }), + }, + }); }; const findMainHeading = () => wrapper.find('h1'); @@ -108,38 +129,13 @@ describe('App component', () => { const findAutoDevopsEnabledAlert = () => wrapper.findComponent(AutoDevopsEnabledAlert); const findVulnerabilityManagementTab = () => wrapper.findByTestId('vulnerability-management-tab'); - const securityFeaturesMock = [ - { - name: SAST_NAME, - shortName: SAST_SHORT_NAME, - description: SAST_DESCRIPTION, - helpPath: SAST_HELP_PATH, - configurationHelpPath: SAST_CONFIG_HELP_PATH, - type: REPORT_TYPE_SAST, - available: true, - }, - ]; - - const complianceFeaturesMock = [ - { - name: LICENSE_COMPLIANCE_NAME, - description: LICENSE_COMPLIANCE_DESCRIPTION, - helpPath: LICENSE_COMPLIANCE_HELP_PATH, - type: REPORT_TYPE_LICENSE_COMPLIANCE, - configurationHelpPath: LICENSE_COMPLIANCE_HELP_PATH, - }, - ]; - afterEach(() => { wrapper.destroy(); }); describe('basic structure', () => { - beforeEach(async () => { - createComponent({ - augmentedSecurityFeatures: securityFeaturesMock, - augmentedComplianceFeatures: complianceFeaturesMock, - }); + beforeEach(() => { + createComponent(); }); it('renders main-heading with correct text', () => { @@ -199,10 +195,7 @@ describe('App component', () => { describe('Manage via MR Error Alert', () => { beforeEach(() => { - createComponent({ - augmentedSecurityFeatures: securityFeaturesMock, - augmentedComplianceFeatures: complianceFeaturesMock, - }); + createComponent(); }); describe('on initial load', () => { @@ -238,8 +231,6 @@ describe('App component', () => { describe('given the right props', () => { beforeEach(() => { createComponent({ - augmentedSecurityFeatures: securityFeaturesMock, - augmentedComplianceFeatures: complianceFeaturesMock, autoDevopsEnabled: false, gitlabCiPresent: false, canEnableAutoDevops: true, @@ -261,10 +252,7 @@ describe('App component', () => { describe('given the wrong props', () => { beforeEach(() => { - createComponent({ - augmentedSecurityFeatures: securityFeaturesMock, - augmentedComplianceFeatures: complianceFeaturesMock, - }); + createComponent(); }); it('should not show AutoDevopsAlert', () => { expect(findAutoDevopsAlert().exists()).toBe(false); @@ -289,8 +277,6 @@ describe('App component', () => { } createComponent({ - augmentedSecurityFeatures: securityFeaturesMock, - augmentedComplianceFeatures: complianceFeaturesMock, autoDevopsEnabled, }); }); @@ -348,7 +334,6 @@ describe('App component', () => { describe('given at least one unavailable feature', () => { beforeEach(() => { createComponent({ - augmentedSecurityFeatures: securityFeaturesMock, augmentedComplianceFeatures: complianceFeaturesMock.map(makeAvailable(false)), }); }); @@ -369,7 +354,6 @@ describe('App component', () => { describe('given at least one unavailable feature, but banner is already dismissed', () => { beforeEach(() => { createComponent({ - augmentedSecurityFeatures: securityFeaturesMock, augmentedComplianceFeatures: complianceFeaturesMock.map(makeAvailable(false)), shouldShowCallout: false, }); @@ -397,8 +381,6 @@ describe('App component', () => { describe('when given latestPipelinePath props', () => { beforeEach(() => { createComponent({ - augmentedSecurityFeatures: securityFeaturesMock, - augmentedComplianceFeatures: complianceFeaturesMock, latestPipelinePath: 'test/path', }); }); @@ -425,8 +407,6 @@ describe('App component', () => { describe('given gitlabCiPresent & gitlabCiHistoryPath props', () => { beforeEach(() => { createComponent({ - augmentedSecurityFeatures: securityFeaturesMock, - augmentedComplianceFeatures: complianceFeaturesMock, gitlabCiPresent: true, gitlabCiHistoryPath, }); @@ -442,42 +422,31 @@ describe('App component', () => { }); describe('Vulnerability management', () => { - it('does not show tab if security training is disabled', () => { + const props = { securityTrainingEnabled: true }; + + beforeEach(async () => { createComponent({ - augmentedSecurityFeatures: securityFeaturesMock, - augmentedComplianceFeatures: complianceFeaturesMock, - securityTrainingEnabled: false, + ...props, }); - - expect(findVulnerabilityManagementTab().exists()).toBe(false); }); - describe('security training enabled', () => { - beforeEach(async () => { - createComponent({ - augmentedSecurityFeatures: securityFeaturesMock, - augmentedComplianceFeatures: complianceFeaturesMock, - }); - }); - - it('shows the tab if security training is enabled', () => { - expect(findVulnerabilityManagementTab().exists()).toBe(true); - }); + it('shows the tab', () => { + expect(findVulnerabilityManagementTab().exists()).toBe(true); + }); - it('renders TrainingProviderList component', () => { - expect(findTrainingProviderList().exists()).toBe(true); - }); + it('renders TrainingProviderList component', () => { + expect(findTrainingProviderList().props()).toMatchObject(props); + }); - it('renders security training description', () => { - expect(findVulnerabilityManagementTab().text()).toContain(i18n.securityTrainingDescription); - }); + it('renders security training description', () => { + expect(findVulnerabilityManagementTab().text()).toContain(i18n.securityTrainingDescription); + }); - it('renders link to help docs', () => { - const trainingLink = findVulnerabilityManagementTab().findComponent(GlLink); + it('renders link to help docs', () => { + const trainingLink = findVulnerabilityManagementTab().findComponent(GlLink); - expect(trainingLink.text()).toBe('Learn more about vulnerability training'); - expect(trainingLink.attributes('href')).toBe(vulnerabilityTrainingDocsPath); - }); + expect(trainingLink.text()).toBe('Learn more about vulnerability training'); + expect(trainingLink.attributes('href')).toBe(vulnerabilityTrainingDocsPath); }); }); }); 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 309a9cd4cd6..184c16fda6e 100644 --- a/spec/frontend/security_configuration/components/training_provider_list_spec.js +++ b/spec/frontend/security_configuration/components/training_provider_list_spec.js @@ -36,7 +36,6 @@ import { testProjectPath, testProviderIds, testProviderName, - tempProviderLogos, } from '../mock_data'; Vue.use(VueApollo); @@ -54,6 +53,31 @@ const TEST_TRAINING_PROVIDERS_ALL_ENABLED = getSecurityTrainingProvidersData({ }); const TEST_TRAINING_PROVIDERS_DEFAULT = TEST_TRAINING_PROVIDERS_ALL_DISABLED; +const TEMP_PROVIDER_LOGOS = { + Kontra: { + svg: '<svg>Kontra</svg>', + }, + 'Secure Code Warrior': { + svg: '<svg>Secure Code Warrior</svg>', + }, +}; +jest.mock('~/security_configuration/components/constants', () => { + return { + TEMP_PROVIDER_URLS: jest.requireActual('~/security_configuration/components/constants') + .TEMP_PROVIDER_URLS, + // NOTE: Jest hoists all mocks to the top so we can't use TEMP_PROVIDER_LOGOS + // here directly. + TEMP_PROVIDER_LOGOS: { + Kontra: { + svg: '<svg>Kontra</svg>', + }, + 'Secure Code Warrior': { + svg: '<svg>Secure Code Warrior</svg>', + }, + }, + }; +}); + describe('TrainingProviderList component', () => { let wrapper; let apolloProvider; @@ -76,7 +100,7 @@ describe('TrainingProviderList component', () => { apolloProvider = createMockApollo(mergedHandlers); }; - const createComponent = () => { + const createComponent = (props = {}) => { wrapper = shallowMountExtended(TrainingProviderList, { provide: { projectFullPath: testProjectPath, @@ -84,6 +108,10 @@ describe('TrainingProviderList component', () => { directives: { GlTooltip: createMockDirective(), }, + propsData: { + securityTrainingEnabled: true, + ...props, + }, apolloProvider, }); }; @@ -99,6 +127,7 @@ describe('TrainingProviderList component', () => { const findLoader = () => wrapper.findComponent(GlSkeletonLoader); const findErrorAlert = () => wrapper.findComponent(GlAlert); const findLogos = () => wrapper.findAllByTestId('provider-logo'); + const findUnavailableTexts = () => wrapper.findAllByTestId('unavailable-text'); const toggleFirstProvider = () => findFirstToggle().vm.$emit('change', testProviderIds[0]); @@ -212,7 +241,6 @@ describe('TrainingProviderList component', () => { describe('provider logo', () => { beforeEach(async () => { - wrapper.vm.$options.TEMP_PROVIDER_LOGOS = tempProviderLogos; await waitForQueryToBeLoaded(); }); @@ -226,9 +254,9 @@ describe('TrainingProviderList component', () => { expect(findLogos().at(provider).attributes('role')).toBe('presentation'); }); - it.each(providerIndexArray)('renders the svg content for provider %s', (provider) => { + it.each(providerIndexArray)('renders the svg content for provider %s', async (provider) => { expect(findLogos().at(provider).html()).toContain( - tempProviderLogos[testProviderName[provider]].svg, + TEMP_PROVIDER_LOGOS[testProviderName[provider]].svg, ); }); }); @@ -351,6 +379,41 @@ describe('TrainingProviderList component', () => { ); }); }); + + describe('non ultimate users', () => { + beforeEach(async () => { + createComponent({ + securityTrainingEnabled: false, + }); + await waitForQueryToBeLoaded(); + }); + + it('displays unavailable text', () => { + findUnavailableTexts().wrappers.forEach((unavailableText) => { + expect(unavailableText.text()).toBe(TrainingProviderList.i18n.unavailableText); + }); + }); + + it('has disabled state for toggle', () => { + findToggles().wrappers.forEach((toggle) => { + expect(toggle.props('disabled')).toBe(true); + }); + }); + + it('has disabled state for radio', () => { + findPrimaryProviderRadios().wrappers.forEach((radio) => { + expect(radio.attributes('disabled')).toBeTruthy(); + }); + }); + + it('adds backgrounds color', () => { + findCards().wrappers.forEach((card) => { + expect(card.props('bodyClass')).toMatchObject({ + 'gl-bg-gray-10': true, + }); + }); + }); + }); }); describe('primary provider settings', () => { @@ -442,7 +505,7 @@ describe('TrainingProviderList component', () => { ${'backend error'} | ${jest.fn().mockReturnValue(dismissUserCalloutErrorResponse)} ${'network error'} | ${jest.fn().mockRejectedValue()} `('when dismissing the callout and a "$errorType" happens', ({ mutationHandler }) => { - beforeEach(async () => { + it('logs the error to sentry', async () => { jest.spyOn(Sentry, 'captureException').mockImplementation(); createApolloProvider({ @@ -460,9 +523,7 @@ describe('TrainingProviderList component', () => { await waitForQueryToBeLoaded(); toggleFirstProvider(); - }); - it('logs the error to sentry', async () => { expect(Sentry.captureException).not.toHaveBeenCalled(); await waitForMutationToBeLoaded(); diff --git a/spec/frontend/security_configuration/mock_data.js b/spec/frontend/security_configuration/mock_data.js index 18a480bf082..2fe3b59cea3 100644 --- a/spec/frontend/security_configuration/mock_data.js +++ b/spec/frontend/security_configuration/mock_data.js @@ -100,14 +100,3 @@ export const updateSecurityTrainingProvidersErrorResponse = { }, }, }; - -// Will remove once this issue is resolved where the svg path will be available in the GraphQL query -// https://gitlab.com/gitlab-org/gitlab/-/issues/346899 -export const tempProviderLogos = { - [testProviderName[0]]: { - svg: `<svg>${[testProviderName[0]]}</svg>`, - }, - [testProviderName[1]]: { - svg: `<svg>${[testProviderName[1]]}</svg>`, - }, -}; diff --git a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap index 62a9ff98243..11841106ed0 100644 --- a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap +++ b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap @@ -8,7 +8,7 @@ exports[`self monitor component When the self monitor project has not been creat class="settings-header" > <h4 - class="js-section-header" + class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only" > Self monitoring 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 0b672cbc93e..e3b5478290a 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 @@ -1,10 +1,11 @@ import { GlModal, GlFormCheckbox } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { initEmojiMock, clearEmojiMock } from 'helpers/emoji'; import * as UserApi from '~/api/user_api'; import EmojiPicker from '~/emoji/components/picker.vue'; import createFlash from '~/flash'; +import stubChildren from 'helpers/stub_children'; import SetStatusModalWrapper, { AVAILABILITY_STATUS, } from '~/set_status_modal/set_status_modal_wrapper.vue'; @@ -26,12 +27,23 @@ describe('SetStatusModalWrapper', () => { defaultEmoji, }; + const EmojiPickerStub = { + props: EmojiPicker.props, + template: '<div></div>', + }; + const createComponent = (props = {}) => { - return shallowMount(SetStatusModalWrapper, { + return mount(SetStatusModalWrapper, { propsData: { ...defaultProps, ...props, }, + stubs: { + ...stubChildren(SetStatusModalWrapper), + GlFormInput: false, + GlFormInputGroup: false, + EmojiPicker: EmojiPickerStub, + }, mocks: { $toast, }, @@ -43,7 +55,7 @@ describe('SetStatusModalWrapper', () => { const findClearStatusButton = () => wrapper.find('.js-clear-user-status-button'); const findAvailabilityCheckbox = () => wrapper.find(GlFormCheckbox); const findClearStatusAtMessage = () => wrapper.find('[data-testid="clear-status-at-message"]'); - const getEmojiPicker = () => wrapper.findComponent(EmojiPicker); + const getEmojiPicker = () => wrapper.findComponent(EmojiPickerStub); const initModal = async ({ mockOnUpdateSuccess = true, mockOnUpdateFailure = true } = {}) => { const modal = findModal(); @@ -88,7 +100,7 @@ describe('SetStatusModalWrapper', () => { }); it('has a clear status button', () => { - expect(findClearStatusButton().isVisible()).toBe(true); + expect(findClearStatusButton().exists()).toBe(true); }); it('displays the clear status at dropdown', () => { @@ -125,7 +137,7 @@ describe('SetStatusModalWrapper', () => { }); it('hides the clear status button', () => { - expect(findClearStatusButton().isVisible()).toBe(false); + expect(findClearStatusButton().exists()).toBe(false); }); }); diff --git a/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js b/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js index a286eeef14f..517b4f12559 100644 --- a/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js +++ b/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js @@ -120,6 +120,7 @@ describe('AssigneeAvatarLink component', () => { it('passes the correct user id for REST API', () => { createComponent({ tooltipHasName: true, + issuableType: 'issue', user: userDataMock(), }); @@ -131,9 +132,22 @@ describe('AssigneeAvatarLink component', () => { createComponent({ tooltipHasName: true, + issuableType: 'issue', user: { ...userDataMock(), id: convertToGraphQLId(TYPE_USER, userId) }, }); expect(findUserLink().attributes('data-user-id')).toBe(String(userId)); }); + + it.each` + issuableType | userId + ${'merge_request'} | ${undefined} + ${'issue'} | ${'1'} + `('it sets data-user-id as $userId for $issuableType', ({ issuableType, userId }) => { + createComponent({ + issuableType, + }); + + expect(findUserLink().attributes('data-user-id')).toBe(userId); + }); }); diff --git a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js index 3ddd41c0bd4..8ebd2dabfc2 100644 --- a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js +++ b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js @@ -107,6 +107,7 @@ describe('SidebarDropdownWidget', () => { currentMilestoneSpy = jest.fn().mockResolvedValue(noCurrentMilestoneResponse), } = {}) => { Vue.use(VueApollo); + mockApollo = createMockApollo([ [projectMilestonesQuery, projectMilestonesSpy], [projectIssueMilestoneQuery, currentMilestoneSpy], @@ -415,11 +416,9 @@ describe('SidebarDropdownWidget', () => { describe('when currentAttribute is not equal to attribute id', () => { describe('when update is successful', () => { - beforeEach(() => { + it('calls setIssueAttribute mutation', () => { findDropdownItemWithText(mockMilestone2.title).vm.$emit('click'); - }); - it('calls setIssueAttribute mutation', () => { expect(milestoneMutationSpy).toHaveBeenCalledWith({ iid: mockIssue.iid, attributeId: getIdFromGraphQLId(mockMilestone2.id), @@ -428,6 +427,8 @@ describe('SidebarDropdownWidget', () => { }); it('sets the value returned from the mutation to currentAttribute', async () => { + findDropdownItemWithText(mockMilestone2.title).vm.$emit('click'); + await nextTick(); expect(findSelectedAttribute().text()).toBe(mockMilestone2.title); }); }); diff --git a/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js b/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js index 549ab99c6af..9a68940590d 100644 --- a/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js +++ b/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js @@ -8,15 +8,22 @@ import createFlash from '~/flash'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import SidebarSubscriptionWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import issueSubscribedQuery from '~/sidebar/queries/issue_subscribed.query.graphql'; -import { issueSubscriptionsResponse } from '../../mock_data'; +import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql'; +import toast from '~/vue_shared/plugins/global_toast'; +import { + issueSubscriptionsResponse, + mergeRequestSubscriptionMutationResponse, +} from '../../mock_data'; jest.mock('~/flash'); +jest.mock('~/vue_shared/plugins/global_toast'); Vue.use(VueApollo); describe('Sidebar Subscriptions Widget', () => { let wrapper; let fakeApollo; + let subscriptionMutationHandler; const findEditableItem = () => wrapper.findComponent(SidebarEditableItem); const findToggle = () => wrapper.findComponent(GlToggle); @@ -24,18 +31,29 @@ describe('Sidebar Subscriptions Widget', () => { const createComponent = ({ subscriptionsQueryHandler = jest.fn().mockResolvedValue(issueSubscriptionsResponse()), + issuableType = 'issue', + movedMrSidebar = false, } = {}) => { - fakeApollo = createMockApollo([[issueSubscribedQuery, subscriptionsQueryHandler]]); + subscriptionMutationHandler = jest + .fn() + .mockResolvedValue(mergeRequestSubscriptionMutationResponse); + fakeApollo = createMockApollo([ + [issueSubscribedQuery, subscriptionsQueryHandler], + [updateMergeRequestSubscriptionMutation, subscriptionMutationHandler], + ]); wrapper = shallowMount(SidebarSubscriptionWidget, { apolloProvider: fakeApollo, provide: { canUpdate: true, + glFeatures: { + movedMrSidebar, + }, }, propsData: { fullPath: 'group/project', iid: '1', - issuableType: 'issue', + issuableType, }, stubs: { SidebarEditableItem, @@ -128,4 +146,21 @@ describe('Sidebar Subscriptions Widget', () => { expect(createFlash).toHaveBeenCalled(); }); + + describe('merge request', () => { + it('displays toast when mutation is successful', async () => { + createComponent({ + issuableType: 'merge_request', + movedMrSidebar: true, + subscriptionsQueryHandler: jest.fn().mockResolvedValue(issueSubscriptionsResponse(true)), + }); + await waitForPromises(); + + await wrapper.find('.dropdown-item').trigger('click'); + + await waitForPromises(); + + expect(toast).toHaveBeenCalledWith('Notifications turned on.'); + }); + }); }); diff --git a/spec/frontend/sidebar/lock/issuable_lock_form_spec.js b/spec/frontend/sidebar/lock/issuable_lock_form_spec.js index 8478d3d674d..bb757fdf63b 100644 --- a/spec/frontend/sidebar/lock/issuable_lock_form_spec.js +++ b/spec/frontend/sidebar/lock/issuable_lock_form_spec.js @@ -1,17 +1,23 @@ import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +import Vuex from 'vuex'; import { mockTracking, triggerEvent } from 'helpers/tracking_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import { createStore as createMrStore } from '~/mr_notes/stores'; import createStore from '~/notes/stores'; import EditForm from '~/sidebar/components/lock/edit_form.vue'; import IssuableLockForm from '~/sidebar/components/lock/issuable_lock_form.vue'; +import toast from '~/vue_shared/plugins/global_toast'; import { ISSUABLE_TYPE_ISSUE, ISSUABLE_TYPE_MR } from './constants'; +jest.mock('~/vue_shared/plugins/global_toast'); + +Vue.use(Vuex); + describe('IssuableLockForm', () => { let wrapper; let store; let issuableType; // Either ISSUABLE_TYPE_ISSUE or ISSUABLE_TYPE_MR + let updateLockedAttribute; const setIssuableType = (pageType) => { issuableType = pageType; @@ -29,16 +35,27 @@ describe('IssuableLockForm', () => { store = createStore(); store.getters.getNoteableData.targetType = 'issue'; } else { - store = createMrStore(); + updateLockedAttribute = jest.fn().mockResolvedValue(); + store = new Vuex.Store({ + getters: { + getNoteableData: () => ({ targetType: issuableType }), + }, + actions: { + updateLockedAttribute, + }, + }); } store.getters.getNoteableData.discussion_locked = isLocked; }; - const createComponent = ({ props = {} }) => { + const createComponent = ({ props = {} }, movedMrSidebar = false) => { wrapper = shallowMount(IssuableLockForm, { store, provide: { fullPath: '', + glFeatures: { + movedMrSidebar, + }, }, propsData: { isEditable: true, @@ -144,4 +161,24 @@ describe('IssuableLockForm', () => { }); }); }); + + describe('merge requests', () => { + beforeEach(() => { + setIssuableType('merge_request'); + }); + + it.each` + locked | message + ${true} | ${'Merge request locked.'} + ${false} | ${'Merge request unlocked.'} + `('displays $message when merge request is $locked', async ({ locked, message }) => { + initStore(locked); + + createComponent({}, true); + + await wrapper.find('.dropdown-item').trigger('click'); + + expect(toast).toHaveBeenCalledWith(message); + }); + }); }); diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js index 2b421037339..229757ff40c 100644 --- a/spec/frontend/sidebar/mock_data.js +++ b/spec/frontend/sidebar/mock_data.js @@ -321,6 +321,19 @@ export const issueSubscriptionsResponse = (subscribed = false, emailsDisabled = }, }); +export const mergeRequestSubscriptionMutationResponse = { + data: { + updateIssuableSubscription: { + issuable: { + __typename: 'MergeRequest', + id: 'gid://gitlab/MergeRequest/4', + subscribed: true, + }, + errors: [], + }, + }, +}; + export const issuableQueryResponse = { data: { workspace: { diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js index 8a767765149..f49ceb2fede 100644 --- a/spec/frontend/snippets/components/edit_spec.js +++ b/spec/frontend/snippets/components/edit_spec.js @@ -5,6 +5,7 @@ import { merge } from 'lodash'; import VueApollo, { ApolloMutation } from 'vue-apollo'; import { useFakeDate } from 'helpers/fake_date'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { stubPerformanceWebAPI } from 'helpers/performance'; import waitForPromises from 'helpers/wait_for_promises'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import GetSnippetQuery from 'shared_queries/snippet/snippet.query.graphql'; @@ -96,6 +97,8 @@ describe('Snippet Edit app', () => { const originalRelativeUrlRoot = gon.relative_url_root; beforeEach(() => { + stubPerformanceWebAPI(); + getSpy = jest.fn().mockResolvedValue(createQueryResponse()); // See `mutateSpy` declaration comment for why we send a key diff --git a/spec/frontend/snippets/components/show_spec.js b/spec/frontend/snippets/components/show_spec.js index c73bf8f80a2..b29ed97099f 100644 --- a/spec/frontend/snippets/components/show_spec.js +++ b/spec/frontend/snippets/components/show_spec.js @@ -12,6 +12,7 @@ import { SNIPPET_VISIBILITY_PUBLIC, } from '~/snippets/constants'; import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue'; +import { stubPerformanceWebAPI } from 'helpers/performance'; describe('Snippet view app', () => { let wrapper; @@ -45,6 +46,10 @@ describe('Snippet view app', () => { const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findEmbedDropdown = () => wrapper.findComponent(EmbedDropdown); + beforeEach(() => { + stubPerformanceWebAPI(); + }); + afterEach(() => { wrapper.destroy(); }); diff --git a/spec/frontend/surveys/merge_request_performance/app_spec.js b/spec/frontend/surveys/merge_request_performance/app_spec.js new file mode 100644 index 00000000000..6e8cc660b1d --- /dev/null +++ b/spec/frontend/surveys/merge_request_performance/app_spec.js @@ -0,0 +1,143 @@ +import { nextTick } from 'vue'; +import { GlButton, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking } from 'helpers/tracking_helper'; +import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser'; +import MergeRequestExperienceSurveyApp from '~/surveys/merge_request_experience/app.vue'; +import SatisfactionRate from '~/surveys/components/satisfaction_rate.vue'; + +describe('MergeRequestExperienceSurveyApp', () => { + let trackingSpy; + let wrapper; + let dismiss; + let dismisserComponent; + + const findCloseButton = () => + wrapper + .findAllComponents(GlButton) + .filter((button) => button.attributes('aria-label') === 'Close') + .at(0); + + const createWrapper = ({ shouldShowCallout = true } = {}) => { + dismiss = jest.fn(); + dismisserComponent = makeMockUserCalloutDismisser({ + dismiss, + shouldShowCallout, + }); + wrapper = shallowMountExtended(MergeRequestExperienceSurveyApp, { + stubs: { + UserCalloutDismisser: dismisserComponent, + GlSprintf, + }, + }); + }; + + describe('when user callout is visible', () => { + beforeEach(() => { + createWrapper(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + it('shows survey', async () => { + expect(wrapper.html()).toContain('Overall, how satisfied are you with merge requests?'); + expect(wrapper.findComponent(SatisfactionRate).exists()).toBe(true); + expect(wrapper.emitted().close).toBe(undefined); + }); + + it('triggers user callout on close', async () => { + findCloseButton().vm.$emit('click'); + expect(dismiss).toHaveBeenCalledTimes(1); + }); + + it('emits close event on close button click', async () => { + findCloseButton().vm.$emit('click'); + expect(wrapper.emitted()).toMatchObject({ close: [[]] }); + }); + + it('applies correct feature name for user callout', () => { + expect(wrapper.findComponent(dismisserComponent).props('featureName')).toBe( + 'mr_experience_survey', + ); + }); + + it('dismisses user callout on survey rate', async () => { + const rate = wrapper.findComponent(SatisfactionRate); + expect(dismiss).not.toHaveBeenCalled(); + rate.vm.$emit('rate', 5); + expect(dismiss).toHaveBeenCalledTimes(1); + }); + + it('steps through survey steps', async () => { + const rate = wrapper.findComponent(SatisfactionRate); + rate.vm.$emit('rate', 5); + await nextTick(); + expect(wrapper.text()).toContain( + 'How satisfied are you with speed/performance of merge requests?', + ); + }); + + it('tracks survey rates', async () => { + const rate = wrapper.findComponent(SatisfactionRate); + rate.vm.$emit('rate', 5); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'survey:mr_experience', { + value: 5, + label: 'overall', + }); + rate.vm.$emit('rate', 4); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'survey:mr_experience', { + value: 4, + label: 'performance', + }); + }); + + it('shows legal note', async () => { + expect(wrapper.text()).toContain( + 'By continuing, you acknowledge that responses will be used to improve GitLab and in accordance with the GitLab Privacy Policy.', + ); + }); + + it('hides legal note after first step', async () => { + const rate = wrapper.findComponent(SatisfactionRate); + rate.vm.$emit('rate', 5); + await nextTick(); + expect(wrapper.text()).not.toContain( + 'By continuing, you acknowledge that responses will be used to improve GitLab and in accordance with the GitLab Privacy Policy.', + ); + }); + + it('shows disappearing thanks message', async () => { + const rate = wrapper.findComponent(SatisfactionRate); + rate.vm.$emit('rate', 5); + await nextTick(); + rate.vm.$emit('rate', 5); + await nextTick(); + expect(wrapper.text()).toContain('Thank you for your feedback!'); + expect(wrapper.emitted()).toMatchObject({}); + jest.runOnlyPendingTimers(); + expect(wrapper.emitted()).toMatchObject({ close: [[]] }); + }); + }); + + describe('when user callout is hidden', () => { + beforeEach(() => { + createWrapper({ shouldShowCallout: false }); + }); + + it('emits close event', async () => { + expect(wrapper.emitted()).toMatchObject({ close: [[]] }); + }); + }); + + describe('when Escape key is pressed', () => { + beforeEach(() => { + createWrapper(); + const event = new KeyboardEvent('keyup', { key: 'Escape' }); + document.dispatchEvent(event); + }); + + it('emits close event', async () => { + expect(wrapper.emitted()).toMatchObject({ close: [[]] }); + expect(dismiss).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/spec/frontend/tabs/index_spec.js b/spec/frontend/tabs/index_spec.js index 67e3d707adb..1d61d38a488 100644 --- a/spec/frontend/tabs/index_spec.js +++ b/spec/frontend/tabs/index_spec.js @@ -1,9 +1,16 @@ -import { GlTabsBehavior, TAB_SHOWN_EVENT } from '~/tabs'; +import { GlTabsBehavior, TAB_SHOWN_EVENT, HISTORY_TYPE_HASH } from '~/tabs'; import { ACTIVE_PANEL_CLASS, ACTIVE_TAB_CLASSES } from '~/tabs/constants'; +import { getLocationHash } from '~/lib/utils/url_utility'; +import { NO_SCROLL_TO_HASH_CLASS } from '~/lib/utils/common_utils'; import { getFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import setWindowLocation from 'helpers/set_window_location_helper'; const tabsFixture = getFixture('tabs/tabs.html'); +global.CSS = { + escape: (val) => val, +}; + describe('GlTabsBehavior', () => { let glTabs; let tabShownEventSpy; @@ -41,6 +48,7 @@ describe('GlTabsBehavior', () => { }); expect(panel.classList.contains(ACTIVE_PANEL_CLASS)).toBe(true); + expect(panel.classList.contains(NO_SCROLL_TO_HASH_CLASS)).toBe(true); }; const expectInactiveTabAndPanel = (name) => { @@ -67,6 +75,7 @@ describe('GlTabsBehavior', () => { }); expect(panel.classList.contains(ACTIVE_PANEL_CLASS)).toBe(false); + expect(panel.classList.contains(NO_SCROLL_TO_HASH_CLASS)).toBe(true); }; const expectGlTabShownEvent = (name) => { @@ -263,4 +272,98 @@ describe('GlTabsBehavior', () => { expectInactiveTabAndPanel('foo'); }); }); + + describe('using history=hash', () => { + const defaultTab = 'foo'; + let tab; + let tabsEl; + + beforeEach(() => { + setHTMLFixture(tabsFixture); + tabsEl = findByTestId('tabs'); + }); + + afterEach(() => { + glTabs.destroy(); + resetHTMLFixture(); + }); + + describe('when a hash exists onInit', () => { + beforeEach(() => { + tab = 'bar'; + setWindowLocation(`http://foo.com/index#${tab}`); + glTabs = new GlTabsBehavior(tabsEl, { history: HISTORY_TYPE_HASH }); + }); + + it('sets the active tab to the hash and preserves hash', () => { + expectActiveTabAndPanel(tab); + expect(getLocationHash()).toBe(tab); + }); + }); + + describe('when a hash does not exist onInit', () => { + beforeEach(() => { + setWindowLocation(`http://foo.com/index`); + glTabs = new GlTabsBehavior(tabsEl, { history: HISTORY_TYPE_HASH }); + }); + + it('sets the active tab to the first tab and sets hash', () => { + expectActiveTabAndPanel(defaultTab); + expect(getLocationHash()).toBe(defaultTab); + }); + }); + + describe('clicking on an inactive tab', () => { + beforeEach(() => { + tab = 'qux'; + setWindowLocation(`http://foo.com/index`); + glTabs = new GlTabsBehavior(tabsEl, { history: HISTORY_TYPE_HASH }); + + findTab(tab).click(); + }); + + it('changes the tabs and updates the hash', () => { + expectInactiveTabAndPanel(defaultTab); + expectActiveTabAndPanel(tab); + expect(getLocationHash()).toBe(tab); + }); + }); + + describe('keyboard navigation', () => { + const secondTab = 'bar'; + + beforeEach(() => { + setWindowLocation(`http://foo.com/index`); + glTabs = new GlTabsBehavior(tabsEl, { history: HISTORY_TYPE_HASH }); + }); + + it.each(['ArrowRight', 'ArrowDown'])( + 'pressing %s moves to next tab and updates hash', + (code) => { + expectActiveTabAndPanel(defaultTab); + + triggerKeyDown(code, glTabs.activeTab); + + expectInactiveTabAndPanel(defaultTab); + expectActiveTabAndPanel(secondTab); + expect(getLocationHash()).toBe(secondTab); + }, + ); + + it.each(['ArrowLeft', 'ArrowUp'])( + 'pressing %s moves to previous tab and updates hash', + (code) => { + // First, make the 2nd tab active + findTab(secondTab).click(); + expectActiveTabAndPanel(secondTab); + + triggerKeyDown(code, glTabs.activeTab); + + expectInactiveTabAndPanel(secondTab); + expectActiveTabAndPanel(defaultTab); + expect(getLocationHash()).toBe(defaultTab); + }, + ); + }); + }); }); diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js index 6c336152e9a..b4626625f31 100644 --- a/spec/frontend/test_setup.js +++ b/spec/frontend/test_setup.js @@ -2,8 +2,6 @@ import 'helpers/shared_test_setup'; import { initializeTestTimeout } from 'helpers/timeout'; -jest.mock('~/lib/utils/axios_utils', () => jest.requireActual('helpers/mocks/axios_utils')); - initializeTestTimeout(process.env.CI ? 6000 : 500); afterEach(() => diff --git a/spec/frontend/tracking/tracking_spec.js b/spec/frontend/tracking/tracking_spec.js index 08da3a9a465..4871644d99f 100644 --- a/spec/frontend/tracking/tracking_spec.js +++ b/spec/frontend/tracking/tracking_spec.js @@ -412,7 +412,7 @@ describe('Tracking', () => { Tracking.setAnonymousUrls(); expect(snowplowSpy).not.toHaveBeenCalledWith('setReferrerUrl', testUrl); - expect(localStorage.getItem(URLS_CACHE_STORAGE_KEY)).not.toContain(oldTimestamp); + expect(localStorage.getItem(URLS_CACHE_STORAGE_KEY)).not.toContain(oldTimestamp.toString()); }); }); }); diff --git a/spec/frontend/user_lists/store/edit/mutations_spec.js b/spec/frontend/user_lists/store/edit/mutations_spec.js index 7971906429b..e07d9c0a1b5 100644 --- a/spec/frontend/user_lists/store/edit/mutations_spec.js +++ b/spec/frontend/user_lists/store/edit/mutations_spec.js @@ -35,7 +35,7 @@ describe('User List Edit Mutations', () => { }); }); - describe(types.RECIEVE_USER_LIST_ERROR, () => { + describe(types.RECEIVE_USER_LIST_ERROR, () => { beforeEach(() => { mutations[types.RECEIVE_USER_LIST_ERROR](state, ['network error']); }); @@ -44,7 +44,7 @@ describe('User List Edit Mutations', () => { expect(state.status).toBe(statuses.ERROR); }); - it('sets the error message to the recieved one', () => { + it('sets the error message to the received one', () => { expect(state.errorMessage).toEqual(['network error']); }); }); diff --git a/spec/frontend/user_lists/store/new/mutations_spec.js b/spec/frontend/user_lists/store/new/mutations_spec.js index a928849e941..647ddd9c062 100644 --- a/spec/frontend/user_lists/store/new/mutations_spec.js +++ b/spec/frontend/user_lists/store/new/mutations_spec.js @@ -9,7 +9,7 @@ describe('User List Edit Mutations', () => { state = createState({ projectId: '1' }); }); - describe(types.RECIEVE_USER_LIST_ERROR, () => { + describe(types.RECEIVE_USER_LIST_ERROR, () => { beforeEach(() => { mutations[types.RECEIVE_CREATE_USER_LIST_ERROR](state, ['network error']); }); diff --git a/spec/frontend/user_popovers_spec.js b/spec/frontend/user_popovers_spec.js index 1544fed5240..b171c8fc9ed 100644 --- a/spec/frontend/user_popovers_spec.js +++ b/spec/frontend/user_popovers_spec.js @@ -12,12 +12,8 @@ jest.mock('~/api/user_api', () => ({ describe('User Popovers', () => { const fixtureTemplate = 'merge_requests/merge_request_with_mentions.html'; - const selector = '.js-user-link, .gfm-project_member'; - const findFixtureLinks = () => { - return Array.from(document.querySelectorAll(selector)).filter( - ({ dataset }) => dataset.user || dataset.userId, - ); - }; + const selector = '.js-user-link[data-user], .js-user-link[data-user-id]'; + const findFixtureLinks = () => Array.from(document.querySelectorAll(selector)); const createUserLink = () => { const link = document.createElement('a'); @@ -95,6 +91,24 @@ describe('User Popovers', () => { }); }); + it('does not initialize the popovers for group references', async () => { + const [groupLink] = Array.from(document.querySelectorAll('.js-user-link[data-group]')); + + triggerEvent('mouseover', groupLink); + jest.runOnlyPendingTimers(); + + expect(findPopovers().length).toBe(0); + }); + + it('does not initialize the popovers for @all references', async () => { + const [projectLink] = Array.from(document.querySelectorAll('.js-user-link[data-project]')); + + triggerEvent('mouseover', projectLink); + jest.runOnlyPendingTimers(); + + expect(findPopovers().length).toBe(0); + }); + it('does not initialize the user popovers twice for the same element', async () => { const [firstUserLink] = findFixtureLinks(); triggerEvent('mouseover', firstUserLink); 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 6386746aee4..6db82cedd80 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,6 +2,9 @@ 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 toast from '~/vue_shared/plugins/global_toast'; + +jest.mock('~/vue_shared/plugins/global_toast'); let wrapper; @@ -261,6 +264,7 @@ describe('Merge request widget rebase component', () => { return Promise.resolve({ data: { rebase_in_progress: false, + should_be_rebased: false, merge_error: null, }, }); @@ -280,6 +284,7 @@ describe('Merge request widget rebase component', () => { await nextTick(); expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetRebaseSuccess'); + expect(toast).toHaveBeenCalledWith('Rebase completed'); }); }); }); diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_action_button_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_action_button_spec.js index 8c5036e35f6..7e7438bcc0f 100644 --- a/spec/frontend/vue_mr_widget/deployment/deployment_action_button_spec.js +++ b/spec/frontend/vue_mr_widget/deployment/deployment_action_button_spec.js @@ -60,7 +60,7 @@ describe('Deployment action button', () => { it('renders slot and icon prop correctly', () => { expect(wrapper.find(GlIcon).exists()).toBe(true); - expect(wrapper.text()).toContain(actionButtonMocks[DEPLOYING]); + expect(wrapper.text()).toContain(actionButtonMocks[DEPLOYING].toString()); }); }); diff --git a/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js b/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js index da4b990c078..5c1d3c8e8e8 100644 --- a/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js +++ b/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js @@ -1,4 +1,4 @@ -import { GlButton } from '@gitlab/ui'; +import { nextTick } from 'vue'; import MockAdapter from 'axios-mock-adapter'; import testReportExtension from '~/vue_merge_request_widget/extensions/test_report'; import { i18n } from '~/vue_merge_request_widget/extensions/test_report/constants'; @@ -38,7 +38,8 @@ describe('Test report extension', () => { }; const findToggleCollapsedButton = () => wrapper.findByTestId('toggle-button'); - const findTertiaryButton = () => wrapper.find(GlButton); + const findFullReportLink = () => wrapper.findByTestId('full-report-link'); + const findCopyFailedSpecsBtn = () => wrapper.findByTestId('copy-failed-specs-btn'); const findAllExtensionListItems = () => wrapper.findAllByTestId('extension-list-item'); const findModal = () => wrapper.find(TestCaseDetails); @@ -72,14 +73,23 @@ describe('Test report extension', () => { }); describe('summary', () => { - it('displays loading text', () => { + it('displays loading state initially', () => { mockApi(httpStatusCodes.OK); createComponent(); expect(wrapper.text()).toContain(i18n.loading); }); - it('displays failed loading text', async () => { + it('with a 204 response, continues to display loading state', async () => { + mockApi(httpStatusCodes.NO_CONTENT, ''); + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toContain(i18n.loading); + }); + + it('with an error response, displays failed to load text', async () => { mockApi(httpStatusCodes.INTERNAL_SERVER_ERROR); createComponent(); @@ -121,8 +131,57 @@ describe('Test report extension', () => { await waitForPromises(); - expect(findTertiaryButton().text()).toBe('Full report'); - expect(findTertiaryButton().attributes('href')).toBe('pipeline/path/test_report'); + expect(findFullReportLink().text()).toBe('Full report'); + expect(findFullReportLink().attributes('href')).toBe('pipeline/path/test_report'); + }); + + it('hides copy failed tests button when there are no failing tests', async () => { + mockApi(httpStatusCodes.OK); + createComponent(); + + await waitForPromises(); + + expect(findCopyFailedSpecsBtn().exists()).toBe(false); + }); + + it('displays copy failed tests button when there are failing tests', async () => { + mockApi(httpStatusCodes.OK, newFailedTestReports); + createComponent(); + + await waitForPromises(); + + expect(findCopyFailedSpecsBtn().exists()).toBe(true); + expect(findCopyFailedSpecsBtn().text()).toBe(i18n.copyFailedSpecs); + expect(findCopyFailedSpecsBtn().attributes('data-clipboard-text')).toBe( + 'spec/file_1.rb spec/file_2.rb', + ); + }); + + it('copy failed tests button updates tooltip text when clicked', async () => { + mockApi(httpStatusCodes.OK, newFailedTestReports); + createComponent(); + + await waitForPromises(); + + // original tooltip shows up + expect(findCopyFailedSpecsBtn().attributes()).toMatchObject({ + title: i18n.copyFailedSpecsTooltip, + }); + + await findCopyFailedSpecsBtn().trigger('click'); + + // tooltip text is replaced for 1 second + expect(findCopyFailedSpecsBtn().attributes()).toMatchObject({ + title: 'Copied', + }); + + jest.runAllTimers(); + await nextTick(); + + // tooltip reverts back to original string + expect(findCopyFailedSpecsBtn().attributes()).toMatchObject({ + title: i18n.copyFailedSpecsTooltip, + }); }); it('shows an error when a suite has a parsing error', async () => { diff --git a/spec/frontend/vue_mr_widget/extensions/test_report/utils_spec.js b/spec/frontend/vue_mr_widget/extensions/test_report/utils_spec.js new file mode 100644 index 00000000000..69ea70549fe --- /dev/null +++ b/spec/frontend/vue_mr_widget/extensions/test_report/utils_spec.js @@ -0,0 +1,242 @@ +import * as utils from '~/vue_merge_request_widget/extensions/test_report/utils'; + +describe('test report widget extension utils', () => { + describe('summaryTextbuilder', () => { + it('should render text for no changed results in multiple tests', () => { + const name = 'Test summary'; + const data = { total: 10 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe( + 'Test summary: %{strong_start}no%{strong_end} changed test results, %{strong_start}10%{strong_end} total tests', + ); + }); + + it('should render text for no changed results in one test', () => { + const name = 'Test summary'; + const data = { total: 1 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe( + 'Test summary: %{strong_start}no%{strong_end} changed test results, %{strong_start}1%{strong_end} total test', + ); + }); + + it('should render text for multiple failed results', () => { + const name = 'Test summary'; + const data = { failed: 3, total: 10 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe( + 'Test summary: %{strong_start}3%{strong_end} failed, %{strong_start}10%{strong_end} total tests', + ); + }); + + it('should render text for multiple errored results', () => { + const name = 'Test summary'; + const data = { errored: 7, total: 10 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe( + 'Test summary: %{strong_start}7%{strong_end} errors, %{strong_start}10%{strong_end} total tests', + ); + }); + + it('should render text for multiple fixed results', () => { + const name = 'Test summary'; + const data = { resolved: 4, total: 10 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe( + 'Test summary: %{strong_start}4%{strong_end} fixed test results, %{strong_start}10%{strong_end} total tests', + ); + }); + + it('should render text for multiple fixed, and multiple failed results', () => { + const name = 'Test summary'; + const data = { failed: 3, resolved: 4, total: 10 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe( + 'Test summary: %{strong_start}3%{strong_end} failed and %{strong_start}4%{strong_end} fixed test results, %{strong_start}10%{strong_end} total tests', + ); + }); + + it('should render text for a singular fixed, and a singular failed result', () => { + const name = 'Test summary'; + const data = { failed: 1, resolved: 1, total: 10 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe( + 'Test summary: %{strong_start}1%{strong_end} failed and %{strong_start}1%{strong_end} fixed test result, %{strong_start}10%{strong_end} total tests', + ); + }); + + it('should render text for singular failed, errored, and fixed results', () => { + const name = 'Test summary'; + const data = { failed: 1, errored: 1, resolved: 1, total: 10 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe( + 'Test summary: %{strong_start}1%{strong_end} failed, %{strong_start}1%{strong_end} error and %{strong_start}1%{strong_end} fixed test result, %{strong_start}10%{strong_end} total tests', + ); + }); + + it('should render text for multiple failed, errored, and fixed results', () => { + const name = 'Test summary'; + const data = { failed: 2, errored: 3, resolved: 4, total: 10 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe( + 'Test summary: %{strong_start}2%{strong_end} failed, %{strong_start}3%{strong_end} errors and %{strong_start}4%{strong_end} fixed test results, %{strong_start}10%{strong_end} total tests', + ); + }); + }); + + describe('reportTextBuilder', () => { + const name = 'Rspec'; + + it('should render text for no changed results in multiple tests', () => { + const data = { name, summary: { total: 10 } }; + const result = utils.reportTextBuilder(data); + + expect(result).toBe('Rspec: no changed test results, 10 total tests'); + }); + + it('should render text for no changed results in one test', () => { + const data = { name, summary: { total: 1 } }; + const result = utils.reportTextBuilder(data); + + expect(result).toBe('Rspec: no changed test results, 1 total test'); + }); + + it('should render text for multiple failed results', () => { + const data = { name, summary: { failed: 3, total: 10 } }; + const result = utils.reportTextBuilder(data); + + expect(result).toBe('Rspec: 3 failed, 10 total tests'); + }); + + it('should render text for multiple errored results', () => { + const data = { name, summary: { errored: 7, total: 10 } }; + const result = utils.reportTextBuilder(data); + + expect(result).toBe('Rspec: 7 errors, 10 total tests'); + }); + + it('should render text for multiple fixed results', () => { + const data = { name, summary: { resolved: 4, total: 10 } }; + const result = utils.reportTextBuilder(data); + + expect(result).toBe('Rspec: 4 fixed test results, 10 total tests'); + }); + + it('should render text for multiple fixed, and multiple failed results', () => { + const data = { name, summary: { failed: 3, resolved: 4, total: 10 } }; + const result = utils.reportTextBuilder(data); + + expect(result).toBe('Rspec: 3 failed and 4 fixed test results, 10 total tests'); + }); + + it('should render text for a singular fixed, and a singular failed result', () => { + const data = { name, summary: { failed: 1, resolved: 1, total: 10 } }; + const result = utils.reportTextBuilder(data); + + expect(result).toBe('Rspec: 1 failed and 1 fixed test result, 10 total tests'); + }); + + it('should render text for singular failed, errored, and fixed results', () => { + const data = { name, summary: { failed: 1, errored: 1, resolved: 1, total: 10 } }; + const result = utils.reportTextBuilder(data); + + expect(result).toBe('Rspec: 1 failed, 1 error and 1 fixed test result, 10 total tests'); + }); + + it('should render text for multiple failed, errored, and fixed results', () => { + const data = { name, summary: { failed: 2, errored: 3, resolved: 4, total: 10 } }; + const result = utils.reportTextBuilder(data); + + expect(result).toBe('Rspec: 2 failed, 3 errors and 4 fixed test results, 10 total tests'); + }); + }); + + describe('recentFailuresTextBuilder', () => { + it.each` + recentlyFailed | failed | expected + ${0} | ${1} | ${''} + ${1} | ${1} | ${'1 out of 1 failed test has failed more than once in the last 14 days'} + ${1} | ${2} | ${'1 out of 2 failed tests has failed more than once in the last 14 days'} + ${2} | ${3} | ${'2 out of 3 failed tests have failed more than once in the last 14 days'} + `( + 'should render summary for $recentlyFailed out of $failed failures', + ({ recentlyFailed, failed, expected }) => { + const result = utils.recentFailuresTextBuilder({ recentlyFailed, failed }); + + expect(result).toBe(expected); + }, + ); + }); + + describe('countRecentlyFailedTests', () => { + it('counts tests with more than one recent failure in a report', () => { + const report = { + new_failures: [{ recent_failures: { count: 2 } }], + existing_failures: [{ recent_failures: { count: 1 } }], + resolved_failures: [{ recent_failures: { count: 20 } }, { recent_failures: { count: 5 } }], + }; + const result = utils.countRecentlyFailedTests(report); + + expect(result).toBe(3); + }); + + it('counts tests with more than one recent failure in an array of reports', () => { + const reports = [ + { + new_failures: [{ recent_failures: { count: 2 } }], + existing_failures: [ + { recent_failures: { count: 20 } }, + { recent_failures: { count: 5 } }, + ], + resolved_failures: [{ recent_failures: { count: 2 } }], + }, + { + new_failures: [{ recent_failures: { count: 8 } }, { recent_failures: { count: 14 } }], + existing_failures: [{ recent_failures: { count: 1 } }], + resolved_failures: [{ recent_failures: { count: 7 } }, { recent_failures: { count: 5 } }], + }, + ]; + const result = utils.countRecentlyFailedTests(reports); + + expect(result).toBe(8); + }); + + it.each([ + [], + {}, + null, + undefined, + { new_failures: undefined }, + [{ existing_failures: null }], + { resolved_failures: [{}] }, + [{ new_failures: [{ recent_failures: {} }] }], + ])('returns 0 when subject is %s', (subject) => { + const result = utils.countRecentlyFailedTests(subject); + + expect(result).toBe(0); + }); + }); + + describe('formatFilePath', () => { + it.each` + file | expected + ${'./test.js'} | ${'test.js'} + ${'/test.js'} | ${'test.js'} + ${'.//////////////test.js'} | ${'test.js'} + ${'test.js'} | ${'test.js'} + ${'mock/path./test.js'} | ${'mock/path./test.js'} + ${'./mock/path./test.js'} | ${'mock/path./test.js'} + `('should format $file to be $expected', ({ file, expected }) => { + expect(utils.formatFilePath(file)).toBe(expected); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js b/spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js index 6d1b3bb34a5..a06ad930abe 100644 --- a/spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js +++ b/spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js @@ -62,7 +62,7 @@ describe('Accessibility extension', () => { expect(wrapper.text()).toBe('Accessibility scanning failed loading results'); }); - it('displays detected errors', async () => { + it('displays detected errors and is expandable', async () => { mockApi(httpStatusCodes.OK, accessibilityReportResponseErrors); createComponent(); @@ -72,9 +72,10 @@ describe('Accessibility extension', () => { expect(wrapper.text()).toBe( 'Accessibility scanning detected 8 issues for the source branch only', ); + expect(findToggleCollapsedButton().exists()).toBe(true); }); - it('displays no detected errors', async () => { + it('displays no detected errors and is not expandable', async () => { mockApi(httpStatusCodes.OK, accessibilityReportResponseSuccess); createComponent(); @@ -84,6 +85,7 @@ describe('Accessibility extension', () => { expect(wrapper.text()).toBe( 'Accessibility scanning detected no issues for the source branch only', ); + expect(findToggleCollapsedButton().exists()).toBe(false); }); }); diff --git a/spec/frontend/vue_mr_widget/extentions/terraform/index_spec.js b/spec/frontend/vue_mr_widget/extentions/terraform/index_spec.js index 77b3576a3d3..d9faa7b2d25 100644 --- a/spec/frontend/vue_mr_widget/extentions/terraform/index_spec.js +++ b/spec/frontend/vue_mr_widget/extentions/terraform/index_spec.js @@ -142,11 +142,11 @@ describe('Terraform extension', () => { expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith( - 'i_merge_request_widget_terraform_click_full_report', + 'i_code_review_merge_request_widget_terraform_click_full_report', ); expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(1); expect(api.trackRedisCounterEvent).toHaveBeenCalledWith( - 'i_merge_request_widget_terraform_count_click_full_report', + 'i_code_review_merge_request_widget_terraform_count_click_full_report', ); }); }); 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 6abbb052aef..b3af5eba364 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -32,6 +32,7 @@ import { fullReportExtension, noTelemetryExtension, pollingExtension, + pollingFullDataExtension, pollingErrorExtension, multiPollingExtension, } from './test_extensions'; @@ -42,6 +43,13 @@ jest.mock('~/smart_interval'); jest.mock('~/lib/utils/favicon'); +jest.mock('@sentry/browser', () => ({ + setExtra: jest.fn(), + setExtras: jest.fn(), + captureMessage: jest.fn(), + captureException: jest.fn(), +})); + Vue.use(VueApollo); describe('MrWidgetOptions', () => { @@ -66,24 +74,16 @@ describe('MrWidgetOptions', () => { afterEach(() => { mock.restore(); wrapper.destroy(); - wrapper = null; gl.mrWidgetData = {}; gon.features = {}; }); - const createComponent = (mrData = mockData, options = {}, glFeatures = {}) => { - if (wrapper) { - wrapper.destroy(); - } - + const createComponent = (mrData = mockData, options = {}) => { wrapper = mount(MrWidgetOptions, { propsData: { mrData: { ...mrData }, }, - provide: { - glFeatures, - }, ...options, }); @@ -521,7 +521,7 @@ describe('MrWidgetOptions', () => { describe('rendering relatedLinks', () => { beforeEach(() => { - createComponent({ + return createComponent({ ...mockData, issues_links: { closing: ` @@ -531,8 +531,10 @@ describe('MrWidgetOptions', () => { `, }, }); + }); - return nextTick(); + afterEach(() => { + wrapper.destroy(); }); it('renders if there are relatedLinks', () => { @@ -875,8 +877,8 @@ describe('MrWidgetOptions', () => { }); describe('given feature flag is enabled', () => { - beforeEach(() => { - createComponent(); + beforeEach(async () => { + await createComponent(); wrapper.vm.mr.hasCI = false; }); @@ -905,6 +907,19 @@ describe('MrWidgetOptions', () => { }); }); + describe('merge error', () => { + it.each` + state | show | showText + ${'closed'} | ${false} | ${'hides'} + ${'merged'} | ${true} | ${'shows'} + ${'open'} | ${true} | ${'shows'} + `('it $showText merge error when state is $state', ({ state, show }) => { + createComponent({ ...mockData, state, merge_error: 'Error!' }); + + expect(wrapper.find('[data-testid="merge_error"]').exists()).toBe(show); + }); + }); + describe('mock extension', () => { let pollRequest; @@ -917,8 +932,6 @@ describe('MrWidgetOptions', () => { }); afterEach(() => { - pollRequest.mockRestore(); - registeredExtensions.extensions = []; }); @@ -970,16 +983,14 @@ describe('MrWidgetOptions', () => { describe('expansion', () => { it('hides collapse button', async () => { registerExtension(workingExtension(false)); - createComponent(); - await waitForPromises(); + await createComponent(); expect(findExtensionToggleButton().exists()).toBe(false); }); it('shows collapse button', async () => { registerExtension(workingExtension(true)); - createComponent(); - await waitForPromises(); + await createComponent(); expect(findExtensionToggleButton().exists()).toBe(true); }); @@ -997,17 +1008,7 @@ describe('MrWidgetOptions', () => { }); afterEach(() => { - pollRequest.mockRestore(); - registeredExtensions.extensions = []; - - // Clear all left-over timeouts that may be registered in the poll class - let id = window.setTimeout(() => {}, 0); - - while (id > 0) { - window.clearTimeout(id); - id -= 1; - } }); describe('success - multi polling', () => { @@ -1058,87 +1059,81 @@ describe('MrWidgetOptions', () => { describe('success', () => { it('does not make additional requests after poll is successful', async () => { registerExtension(pollingExtension); + await createComponent(); - // called two times due to parent component polling (mount) and extension polling - expect(pollRequest).toHaveBeenCalledTimes(2); + + expect(pollRequest).toHaveBeenCalledTimes(6); }); + }); + + describe('success - full data polling', () => { + it('sets data when polling is complete', async () => { + registerExtension(pollingFullDataExtension); - it('keeps polling when poll-interval header is provided', async () => { - registerExtension({ - ...pollingExtension, - methods: { - ...pollingExtension.methods, - fetchCollapsedData() { - return Promise.resolve({ - data: {}, - headers: { 'poll-interval': 1 }, - status: 204, - }); - }, - }, - }); await createComponent(); - expect(findWidgetTestExtension().html()).toContain('Test extension loading...'); + + api.trackRedisHllUserEvent.mockClear(); + api.trackRedisCounterEvent.mockClear(); + + findExtensionToggleButton().trigger('click'); + + // The default working extension is a "warning" type, which generates a second - more specific - telemetry event for expansions + expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(2); + expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith( + 'i_code_review_merge_request_widget_test_extension_expand', + ); + expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith( + 'i_code_review_merge_request_widget_test_extension_expand_warning', + ); + expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(2); + expect(api.trackRedisCounterEvent).toHaveBeenCalledWith( + 'i_code_review_merge_request_widget_test_extension_count_expand', + ); + expect(api.trackRedisCounterEvent).toHaveBeenCalledWith( + 'i_code_review_merge_request_widget_test_extension_count_expand_warning', + ); }); }); describe('error', () => { - let captureException; - - beforeEach(() => { - captureException = jest.spyOn(Sentry, 'captureException'); - + it('does not make additional requests after poll has failed', async () => { registerExtension(pollingErrorExtension); + await createComponent(); - createComponent(); + expect(pollRequest).toHaveBeenCalledTimes(6); }); - 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); - }); + it('captures sentry error and displays error when poll has failed', async () => { + registerExtension(pollingErrorExtension); + await createComponent(); - it('captures sentry error and displays error when poll has failed', () => { - expect(captureException).toHaveBeenCalledTimes(1); - expect(captureException).toHaveBeenCalledWith(new Error('Fetch error')); + expect(Sentry.captureException).toHaveBeenCalledTimes(5); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Fetch error')); expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed'); }); }); }); describe('mock extension errors', () => { - let captureException; - - const itHandlesTheException = () => { - expect(captureException).toHaveBeenCalledTimes(1); - expect(captureException).toHaveBeenCalledWith(new Error('Fetch error')); - expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed'); - }; - - beforeEach(() => { - captureException = jest.spyOn(Sentry, 'captureException'); - }); - afterEach(() => { registeredExtensions.extensions = []; - captureException = null; }); it('handles collapsed data fetch errors', async () => { registerExtension(collapsedDataErrorExtension); - createComponent(); - await waitForPromises(); + await createComponent(); expect( wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]').exists(), ).toBe(false); - itHandlesTheException(); + expect(Sentry.captureException).toHaveBeenCalledTimes(5); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Fetch error')); + expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed'); }); it('handles full data fetch errors', async () => { registerExtension(fullDataErrorExtension); - createComponent(); - await waitForPromises(); + await createComponent(); expect(wrapper.findComponent(StatusIcon).props('iconName')).not.toBe('error'); wrapper @@ -1148,7 +1143,9 @@ describe('MrWidgetOptions', () => { await nextTick(); await waitForPromises(); - itHandlesTheException(); + expect(Sentry.captureException).toHaveBeenCalledTimes(1); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Fetch error')); + expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed'); }); }); @@ -1163,11 +1160,11 @@ describe('MrWidgetOptions', () => { expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith( - 'i_merge_request_widget_test_extension_view', + 'i_code_review_merge_request_widget_test_extension_view', ); expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(1); expect(api.trackRedisCounterEvent).toHaveBeenCalledWith( - 'i_merge_request_widget_test_extension_count_view', + 'i_code_review_merge_request_widget_test_extension_count_view', ); }); @@ -1186,17 +1183,17 @@ describe('MrWidgetOptions', () => { // The default working extension is a "warning" type, which generates a second - more specific - telemetry event for expansions expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(2); expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith( - 'i_merge_request_widget_test_extension_expand', + 'i_code_review_merge_request_widget_test_extension_expand', ); expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith( - 'i_merge_request_widget_test_extension_expand_warning', + 'i_code_review_merge_request_widget_test_extension_expand_warning', ); expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(2); expect(api.trackRedisCounterEvent).toHaveBeenCalledWith( - 'i_merge_request_widget_test_extension_count_expand', + 'i_code_review_merge_request_widget_test_extension_count_expand', ); expect(api.trackRedisCounterEvent).toHaveBeenCalledWith( - 'i_merge_request_widget_test_extension_count_expand_warning', + 'i_code_review_merge_request_widget_test_extension_count_expand_warning', ); }); @@ -1239,11 +1236,11 @@ describe('MrWidgetOptions', () => { expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith( - 'i_merge_request_widget_test_extension_click_full_report', + 'i_code_review_merge_request_widget_test_extension_click_full_report', ); expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(1); expect(api.trackRedisCounterEvent).toHaveBeenCalledWith( - 'i_merge_request_widget_test_extension_count_click_full_report', + 'i_code_review_merge_request_widget_test_extension_count_click_full_report', ); }); diff --git a/spec/frontend/vue_mr_widget/test_extensions.js b/spec/frontend/vue_mr_widget/test_extensions.js index 76644e0be77..1977f550577 100644 --- a/spec/frontend/vue_mr_widget/test_extensions.js +++ b/spec/frontend/vue_mr_widget/test_extensions.js @@ -109,6 +109,39 @@ export const pollingExtension = { enablePolling: true, }; +export const pollingFullDataExtension = { + ...workingExtension(), + enableExpandedPolling: true, + methods: { + fetchCollapsedData({ targetProjectFullPath }) { + return Promise.resolve({ targetProjectFullPath, count: 1 }); + }, + fetchFullData() { + return Promise.resolve([ + { + headers: { 'poll-interval': 0 }, + status: 200, + data: { + id: 1, + text: 'Hello world', + icon: { + name: EXTENSION_ICONS.failed, + }, + badge: { + text: 'Closed', + }, + link: { + href: 'https://gitlab.com', + text: 'GitLab.com', + }, + actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }], + }, + }, + ]); + }, + }, +}; + export const fullReportExtension = { ...workingExtension(), computed: { diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js index 93b59800c27..441e21ee905 100644 --- a/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js +++ b/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js @@ -84,15 +84,15 @@ describe('LabelsSelectRoot', () => { }); describe('if the variant is `sidebar`', () => { - beforeEach(() => { + it('renders SidebarEditableItem component', () => { createComponent(); - }); - it('renders SidebarEditableItem component', () => { expect(findSidebarEditableItem().exists()).toBe(true); }); it('renders correct props for the SidebarEditableItem component', () => { + createComponent(); + expect(findSidebarEditableItem().props()).toMatchObject({ title: wrapper.vm.$options.i18n.widgetTitle, canEdit: defaultProps.allowEdit, @@ -135,7 +135,7 @@ describe('LabelsSelectRoot', () => { it('handles DropdownContents setColor', () => { findDropdownContents().vm.$emit('setColor', color); - expect(wrapper.emitted('updateSelectedColor')).toEqual([[color]]); + expect(wrapper.emitted('updateSelectedColor')).toEqual([[{ color }]]); }); }); @@ -157,20 +157,24 @@ describe('LabelsSelectRoot', () => { createComponent({ propsData: { iid: undefined } }); findDropdownContents().vm.$emit('setColor', color); - expect(wrapper.emitted('updateSelectedColor')).toEqual([[color]]); + expect(wrapper.emitted('updateSelectedColor')).toEqual([[{ color }]]); }); describe('when updating color for epic', () => { - beforeEach(() => { + const setup = () => { createComponent(); findDropdownContents().vm.$emit('setColor', color); - }); + }; it('sets the loading state', () => { + setup(); + expect(findSidebarEditableItem().props('loading')).toBe(true); }); it('updates color correctly after successful mutation', async () => { + setup(); + await waitForPromises(); expect(findDropdownValue().props('selectedColor').color).toEqual( updateColorMutationResponse.data.updateIssuableColor.issuable.color, diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js index 74f50b878e2..ee4d3a2630a 100644 --- a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js +++ b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js @@ -1,57 +1,30 @@ -import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import { GlDropdown } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { DROPDOWN_VARIANT } from '~/vue_shared/components/color_select_dropdown/constants'; import DropdownContents from '~/vue_shared/components/color_select_dropdown/dropdown_contents.vue'; import DropdownContentsColorView from '~/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue'; +import DropdownHeader from '~/vue_shared/components/color_select_dropdown/dropdown_header.vue'; import { color } from './mock_data'; -const showDropdown = jest.fn(); -const focusInput = jest.fn(); - const defaultProps = { dropdownTitle: '', selectedColor: color, - dropdownButtonText: '', + dropdownButtonText: 'Pick a color', variant: '', isVisible: false, }; -const GlDropdownStub = { - template: ` - <div> - <slot name="header"></slot> - <slot></slot> - </div> - `, - methods: { - show: showDropdown, - hide: jest.fn(), - }, -}; - -const DropdownHeaderStub = { - template: ` - <div>Hello, I am a header</div> - `, - methods: { - focusInput, - }, -}; - describe('DropdownContent', () => { let wrapper; const createComponent = ({ propsData = {} } = {}) => { - wrapper = shallowMount(DropdownContents, { + wrapper = mountExtended(DropdownContents, { propsData: { ...defaultProps, ...propsData, }, - stubs: { - GlDropdown: GlDropdownStub, - DropdownHeader: DropdownHeaderStub, - }, }); }; @@ -60,16 +33,17 @@ describe('DropdownContent', () => { }); const findColorView = () => wrapper.findComponent(DropdownContentsColorView); - const findDropdownHeader = () => wrapper.findComponent(DropdownHeaderStub); - const findDropdown = () => wrapper.findComponent(GlDropdownStub); + const findDropdownHeader = () => wrapper.findComponent(DropdownHeader); + const findDropdown = () => wrapper.findComponent(GlDropdown); it('calls dropdown `show` method on `isVisible` prop change', async () => { createComponent(); + const spy = jest.spyOn(wrapper.vm.$refs.dropdown, 'show'); await wrapper.setProps({ isVisible: true, }); - expect(showDropdown).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(1); }); it('does not emit `setColor` event on dropdown hide if color did not change', () => { @@ -110,4 +84,12 @@ describe('DropdownContent', () => { expect(findDropdownHeader().exists()).toBe(true); }); + + it('handles no selected color', () => { + createComponent({ propsData: { selectedColor: {} } }); + + expect(wrapper.findByTestId('fallback-button-text').text()).toEqual( + defaultProps.dropdownButtonText, + ); + }); }); diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js index f22592dd604..5bbdb136353 100644 --- a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js +++ b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js @@ -33,7 +33,7 @@ describe('DropdownValue', () => { it.each` index | cssClass - ${0} | ${['gl-font-base', 'gl-line-height-24']} + ${0} | ${[]} ${1} | ${['hide-collapsed']} `( 'passes correct props to the ColorItem with CSS class `$cssClass`', diff --git a/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js b/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js index e3d8bfd22ca..79001b9282f 100644 --- a/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js +++ b/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js @@ -1,7 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import DeployBoardInstance from '~/vue_shared/components/deployment_instance.vue'; -import { folder } from './mock_data'; describe('Deploy Board Instance', () => { let wrapper; @@ -13,7 +12,6 @@ describe('Deploy Board Instance', () => { ...props, }, provide: { - glFeatures: { monitorLogging: true }, ...provide, }, }); @@ -25,7 +23,6 @@ describe('Deploy Board Instance', () => { it('should render a div with the correct css status and tooltip data', () => { wrapper = createComponent({ - logsPath: folder.logs_path, tooltipText: 'This is a pod', }); @@ -43,17 +40,6 @@ describe('Deploy Board Instance', () => { expect(wrapper.classes('deployment-instance-deploying')).toBe(true); expect(wrapper.attributes('title')).toEqual(''); }); - - it('should have a log path computed with a pod name as a parameter', () => { - wrapper = createComponent({ - logsPath: folder.logs_path, - podName: 'tanuki-1', - }); - - expect(wrapper.vm.computedLogPath).toEqual( - '/root/review-app/-/logs?environment_name=foo&pod_name=tanuki-1', - ); - }); }); describe('as a canary deployment', () => { @@ -76,46 +62,10 @@ describe('Deploy Board Instance', () => { wrapper.destroy(); }); - it('should not be a link without a logsPath prop', async () => { - wrapper = createComponent({ - stable: false, - logsPath: '', - }); - - await nextTick(); - expect(wrapper.vm.computedLogPath).toBeNull(); - expect(wrapper.vm.isLink).toBeFalsy(); - }); - - it('should render a link without href if path is not passed', () => { - wrapper = createComponent(); - - expect(wrapper.attributes('href')).toBeUndefined(); - }); - it('should not have a tooltip', () => { wrapper = createComponent(); expect(wrapper.attributes('title')).toEqual(''); }); }); - - describe(':monitor_logging feature flag', () => { - afterEach(() => { - wrapper.destroy(); - }); - - it.each` - flagState | logsState | expected - ${true} | ${'shows'} | ${'/root/review-app/-/logs?environment_name=foo&pod_name=tanuki-1'} - ${false} | ${'hides'} | ${undefined} - `('$logsState log link when flag state is $flagState', async ({ flagState, expected }) => { - wrapper = createComponent( - { logsPath: folder.logs_path, podName: 'tanuki-1' }, - { glFeatures: { monitorLogging: flagState } }, - ); - - expect(wrapper.attributes('href')).toEqual(expected); - }); - }); }); diff --git a/spec/frontend/vue_shared/components/deployment_instance/mock_data.js b/spec/frontend/vue_shared/components/deployment_instance/mock_data.js index 6618c57948c..098787cd1b4 100644 --- a/spec/frontend/vue_shared/components/deployment_instance/mock_data.js +++ b/spec/frontend/vue_shared/components/deployment_instance/mock_data.js @@ -140,5 +140,4 @@ export const folder = { created_at: '2017-02-01T19:42:18.400Z', updated_at: '2017-02-01T19:42:18.400Z', rollout_status: {}, - logs_path: '/root/review-app/-/logs?environment_name=foo', }; diff --git a/spec/frontend/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js b/spec/frontend/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js index d0fa8b8dacb..16f924b44d8 100644 --- a/spec/frontend/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js +++ b/spec/frontend/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js @@ -1,11 +1,9 @@ import { mount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import { compileToFunctions } from 'vue-template-compiler'; - +import { nextTick } from 'vue'; import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants'; -import imageDiffViewer from '~/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue'; +import ImageDiffViewer from '~/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue'; -describe('ImageDiffViewer', () => { +describe('ImageDiffViewer component', () => { const requiredProps = { diffMode: 'replaced', newPath: GREEN_BOX_IMAGE_URL, @@ -17,15 +15,12 @@ describe('ImageDiffViewer', () => { newSize: 1024, }; let wrapper; - let vm; - function createComponent(props) { - const ImageDiffViewer = Vue.extend(imageDiffViewer); - wrapper = mount(ImageDiffViewer, { propsData: props }); - vm = wrapper.vm; - } + const createComponent = (props, slots) => { + wrapper = mount(ImageDiffViewer, { propsData: props, slots }); + }; - const triggerEvent = (eventName, el = vm.$el, clientX = 0) => { + const triggerEvent = (eventName, el = wrapper.$el, clientX = 0) => { const event = new MouseEvent(eventName, { bubbles: true, cancelable: true, @@ -51,128 +46,76 @@ describe('ImageDiffViewer', () => { wrapper.destroy(); }); - it('renders image diff for replaced', async () => { - createComponent({ ...allProps }); - - await nextTick(); - const metaInfoElements = vm.$el.querySelectorAll('.image-info'); - - expect(vm.$el.querySelector('.added img').getAttribute('src')).toBe(GREEN_BOX_IMAGE_URL); - - expect(vm.$el.querySelector('.deleted img').getAttribute('src')).toBe(RED_BOX_IMAGE_URL); - - expect(vm.$el.querySelector('.view-modes-menu li.active').textContent.trim()).toBe('2-up'); - expect(vm.$el.querySelector('.view-modes-menu li:nth-child(2)').textContent.trim()).toBe( - 'Swipe', - ); - - expect(vm.$el.querySelector('.view-modes-menu li:nth-child(3)').textContent.trim()).toBe( - 'Onion skin', - ); - - expect(metaInfoElements.length).toBe(2); - expect(metaInfoElements[0]).toHaveText('2.00 KiB'); - expect(metaInfoElements[1]).toHaveText('1.00 KiB'); + it('renders image diff for replaced', () => { + createComponent(allProps); + const metaInfoElements = wrapper.findAll('.image-info'); + + expect(wrapper.find('.added img').attributes('src')).toBe(GREEN_BOX_IMAGE_URL); + expect(wrapper.find('.deleted img').attributes('src')).toBe(RED_BOX_IMAGE_URL); + expect(wrapper.find('.view-modes-menu li.active').text()).toBe('2-up'); + expect(wrapper.find('.view-modes-menu li:nth-child(2)').text()).toBe('Swipe'); + expect(wrapper.find('.view-modes-menu li:nth-child(3)').text()).toBe('Onion skin'); + expect(metaInfoElements).toHaveLength(2); + expect(metaInfoElements.at(0).text()).toBe('2.00 KiB'); + expect(metaInfoElements.at(1).text()).toBe('1.00 KiB'); }); - it('renders image diff for new', async () => { + it('renders image diff for new', () => { createComponent({ ...allProps, diffMode: 'new', oldPath: '' }); - await nextTick(); - - const metaInfoElement = vm.$el.querySelector('.image-info'); - - expect(vm.$el.querySelector('.added img').getAttribute('src')).toBe(GREEN_BOX_IMAGE_URL); - expect(metaInfoElement).toHaveText('1.00 KiB'); + expect(wrapper.find('.added img').attributes('src')).toBe(GREEN_BOX_IMAGE_URL); + expect(wrapper.find('.image-info').text()).toBe('1.00 KiB'); }); - it('renders image diff for deleted', async () => { + it('renders image diff for deleted', () => { createComponent({ ...allProps, diffMode: 'deleted', newPath: '' }); - await nextTick(); - - const metaInfoElement = vm.$el.querySelector('.image-info'); - - expect(vm.$el.querySelector('.deleted img').getAttribute('src')).toBe(RED_BOX_IMAGE_URL); - expect(metaInfoElement).toHaveText('2.00 KiB'); + expect(wrapper.find('.deleted img').attributes('src')).toBe(RED_BOX_IMAGE_URL); + expect(wrapper.find('.image-info').text()).toBe('2.00 KiB'); }); - it('renders image diff for renamed', async () => { - vm = new Vue({ - components: { - imageDiffViewer, - }, - data() { - return { - ...allProps, - diffMode: 'renamed', - }; - }, - ...compileToFunctions(` - <image-diff-viewer - :diff-mode="diffMode" - :new-path="newPath" - :old-path="oldPath" - :new-size="newSize" - :old-size="oldSize" - > - <template #image-overlay> - <span class="overlay">test</span> - </template> - </image-diff-viewer> - `), - }).$mount(); - - await nextTick(); - - const metaInfoElement = vm.$el.querySelector('.image-info'); - - expect(vm.$el.querySelector('img').getAttribute('src')).toBe(GREEN_BOX_IMAGE_URL); - expect(vm.$el.querySelector('.overlay')).not.toBe(null); - - expect(metaInfoElement).toHaveText('2.00 KiB'); + it('renders image diff for renamed', () => { + createComponent( + { ...allProps, diffMode: 'renamed' }, + { 'image-overlay': '<span class="overlay">test</span>' }, + ); + + expect(wrapper.find('img').attributes('src')).toBe(GREEN_BOX_IMAGE_URL); + expect(wrapper.find('.overlay').exists()).toBe(true); + expect(wrapper.find('.image-info').text()).toBe('2.00 KiB'); }); describe('swipeMode', () => { beforeEach(() => { - createComponent({ ...requiredProps }); - - return nextTick(); + createComponent(requiredProps); }); it('switches to Swipe Mode', async () => { - vm.$el.querySelector('.view-modes-menu li:nth-child(2)').click(); + await wrapper.find('.view-modes-menu li:nth-child(2)').trigger('click'); - await nextTick(); - expect(vm.$el.querySelector('.view-modes-menu li.active').textContent.trim()).toBe('Swipe'); + expect(wrapper.find('.view-modes-menu li.active').text()).toBe('Swipe'); }); }); describe('onionSkin', () => { beforeEach(() => { createComponent({ ...requiredProps }); - - return nextTick(); }); it('switches to Onion Skin Mode', async () => { - vm.$el.querySelector('.view-modes-menu li:nth-child(3)').click(); + await wrapper.find('.view-modes-menu li:nth-child(3)').trigger('click'); - await nextTick(); - expect(vm.$el.querySelector('.view-modes-menu li.active').textContent.trim()).toBe( - 'Onion skin', - ); + expect(wrapper.find('.view-modes-menu li.active').text()).toBe('Onion skin'); }); it('has working drag handler', async () => { - vm.$el.querySelector('.view-modes-menu li:nth-child(3)').click(); + await wrapper.find('.view-modes-menu li:nth-child(3)').trigger('click'); + dragSlider(wrapper.find('.dragger').element, document, 20); await nextTick(); - dragSlider(vm.$el.querySelector('.dragger'), document, 20); - await nextTick(); - expect(vm.$el.querySelector('.dragger').style.left).toBe('20px'); - expect(vm.$el.querySelector('.added.frame').style.opacity).toBe('0.2'); + expect(wrapper.find('.dragger').attributes('style')).toBe('left: 20px;'); + expect(wrapper.find('.added.frame').attributes('style')).toBe('opacity: 0.2;'); }); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js index e3e2ef5610d..86d1f21fd04 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -8,6 +8,8 @@ import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; import ReleaseToken from '~/vue_shared/components/filtered_search_bar/tokens/release_token.vue'; +import CrmContactToken from '~/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue'; +import CrmOrganizationToken from '~/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue'; export const mockAuthor1 = { id: 1, @@ -62,6 +64,128 @@ export const mockMilestones = [ mockEscapedMilestone, ]; +export const mockCrmContacts = [ + { + id: 'gid://gitlab/CustomerRelations::Contact/1', + firstName: 'John', + lastName: 'Smith', + email: 'john@smith.com', + }, + { + id: 'gid://gitlab/CustomerRelations::Contact/2', + firstName: 'Andy', + lastName: 'Green', + email: 'andy@green.net', + }, +]; + +export const mockCrmOrganizations = [ + { + id: 'gid://gitlab/CustomerRelations::Organization/1', + name: 'First Org Ltd.', + }, + { + id: 'gid://gitlab/CustomerRelations::Organization/2', + name: 'Organizer S.p.a.', + }, +]; + +export const mockProjectCrmContactsQueryResponse = { + data: { + project: { + __typename: 'Project', + id: 1, + group: { + __typename: 'Group', + id: 1, + contacts: { + __typename: 'CustomerRelationsContactConnection', + nodes: [ + { + __typename: 'CustomerRelationsContact', + ...mockCrmContacts[0], + }, + { + __typename: 'CustomerRelationsContact', + ...mockCrmContacts[1], + }, + ], + }, + }, + }, + }, +}; + +export const mockProjectCrmOrganizationsQueryResponse = { + data: { + project: { + __typename: 'Project', + id: 1, + group: { + __typename: 'Group', + id: 1, + organizations: { + __typename: 'CustomerRelationsOrganizationConnection', + nodes: [ + { + __typename: 'CustomerRelationsOrganization', + ...mockCrmOrganizations[0], + }, + { + __typename: 'CustomerRelationsOrganization', + ...mockCrmOrganizations[1], + }, + ], + }, + }, + }, + }, +}; + +export const mockGroupCrmContactsQueryResponse = { + data: { + group: { + __typename: 'Group', + id: 1, + contacts: { + __typename: 'CustomerRelationsContactConnection', + nodes: [ + { + __typename: 'CustomerRelationsContact', + ...mockCrmContacts[0], + }, + { + __typename: 'CustomerRelationsContact', + ...mockCrmContacts[1], + }, + ], + }, + }, + }, +}; + +export const mockGroupCrmOrganizationsQueryResponse = { + data: { + group: { + __typename: 'Group', + id: 1, + organizations: { + __typename: 'CustomerRelationsOrganizationConnection', + nodes: [ + { + __typename: 'CustomerRelationsOrganization', + ...mockCrmOrganizations[0], + }, + { + __typename: 'CustomerRelationsOrganization', + ...mockCrmOrganizations[1], + }, + ], + }, + }, + }, +}; + export const mockEmoji1 = { name: 'thumbsup', }; @@ -134,6 +258,28 @@ export const mockReactionEmojiToken = { fetchEmojis: () => Promise.resolve(mockEmojis), }; +export const mockCrmContactToken = { + type: 'crm_contact', + title: 'Contact', + icon: 'user', + token: CrmContactToken, + isProject: false, + fullPath: 'group', + operators: OPERATOR_IS_ONLY, + unique: true, +}; + +export const mockCrmOrganizationToken = { + type: 'crm_contact', + title: 'Organization', + icon: 'user', + token: CrmOrganizationToken, + isProject: false, + fullPath: 'group', + operators: OPERATOR_IS_ONLY, + unique: true, +}; + export const mockMembershipToken = { type: 'with_inherited_permissions', icon: 'group', diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js index ca8cd419d87..a0126c2bd63 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js @@ -418,8 +418,6 @@ describe('BaseToken', () => { }); it('does not emit `fetch-suggestions` event on component after a delay when component emits `input` event', async () => { - jest.useFakeTimers(); - findGlFilteredSearchToken().vm.$emit('input', { data: 'foo' }); await nextTick(); @@ -437,8 +435,6 @@ describe('BaseToken', () => { }); it('emits `fetch-suggestions` event on component after a delay when component emits `input` event', async () => { - jest.useFakeTimers(); - findGlFilteredSearchToken().vm.$emit('input', { data: 'foo' }); await nextTick(); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js new file mode 100644 index 00000000000..157e021fc60 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js @@ -0,0 +1,283 @@ +import { + GlFilteredSearchSuggestion, + GlFilteredSearchTokenSegment, + GlDropdownDivider, +} from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import Vue, { 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 { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import CrmContactToken from '~/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue'; +import searchCrmContactsQuery from '~/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql'; + +import { + mockCrmContacts, + mockCrmContactToken, + mockGroupCrmContactsQueryResponse, + mockProjectCrmContactsQueryResponse, +} from '../mock_data'; + +jest.mock('~/flash'); + +const defaultStubs = { + Portal: true, + BaseToken, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, +}; + +describe('CrmContactToken', () => { + Vue.use(VueApollo); + + let wrapper; + let fakeApollo; + + const getBaseToken = () => wrapper.findComponent(BaseToken); + + const searchGroupCrmContactsQueryHandler = jest + .fn() + .mockResolvedValue(mockGroupCrmContactsQueryResponse); + const searchProjectCrmContactsQueryHandler = jest + .fn() + .mockResolvedValue(mockProjectCrmContactsQueryResponse); + + const mountComponent = ({ + config = mockCrmContactToken, + value = { data: '' }, + active = false, + stubs = defaultStubs, + listeners = {}, + queryHandler = searchGroupCrmContactsQueryHandler, + } = {}) => { + fakeApollo = createMockApollo([[searchCrmContactsQuery, queryHandler]]); + + wrapper = mount(CrmContactToken, { + propsData: { + config, + value, + active, + cursorPosition: 'start', + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: () => 'custom-class', + }, + stubs, + listeners, + apolloProvider: fakeApollo, + }); + }; + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + describe('methods', () => { + describe('fetchContacts', () => { + describe('for groups', () => { + beforeEach(() => { + mountComponent(); + }); + + it('calls the apollo query providing the searchString when search term is a string', async () => { + getBaseToken().vm.$emit('fetch-suggestions', 'foo'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchGroupCrmContactsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'group', + isProject: false, + searchString: 'foo', + searchIds: null, + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmContacts); + }); + + it('calls the apollo query providing the searchId when search term is a number', async () => { + getBaseToken().vm.$emit('fetch-suggestions', '5'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchGroupCrmContactsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'group', + isProject: false, + searchString: null, + searchIds: ['gid://gitlab/CustomerRelations::Contact/5'], + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmContacts); + }); + }); + + describe('for projects', () => { + beforeEach(() => { + mountComponent({ + config: { + fullPath: 'project', + isProject: true, + }, + queryHandler: searchProjectCrmContactsQueryHandler, + }); + }); + + it('calls the apollo query providing the searchString when search term is a string', async () => { + getBaseToken().vm.$emit('fetch-suggestions', 'foo'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchProjectCrmContactsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'project', + isProject: true, + searchString: 'foo', + searchIds: null, + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmContacts); + }); + + it('calls the apollo query providing the searchId when search term is a number', async () => { + getBaseToken().vm.$emit('fetch-suggestions', '5'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchProjectCrmContactsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'project', + isProject: true, + searchString: null, + searchIds: ['gid://gitlab/CustomerRelations::Contact/5'], + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmContacts); + }); + }); + + it('calls `createFlash` with flash error message when request fails', async () => { + mountComponent(); + + jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); + + getBaseToken().vm.$emit('fetch-suggestions'); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem fetching CRM contacts.', + }); + }); + + it('sets `loading` to false when request completes', async () => { + mountComponent(); + + jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); + + getBaseToken().vm.$emit('fetch-suggestions'); + + await waitForPromises(); + + expect(getBaseToken().props('suggestionsLoading')).toBe(false); + }); + }); + }); + + describe('template', () => { + const defaultContacts = DEFAULT_NONE_ANY; + + it('renders base-token component', () => { + mountComponent({ + config: { ...mockCrmContactToken, initialContacts: mockCrmContacts }, + value: { data: '1' }, + }); + + const baseTokenEl = wrapper.find(BaseToken); + + expect(baseTokenEl.exists()).toBe(true); + expect(baseTokenEl.props()).toMatchObject({ + suggestions: mockCrmContacts, + getActiveTokenValue: wrapper.vm.getActiveContact, + }); + }); + + it.each(mockCrmContacts)('renders token item when value is selected', (contact) => { + mountComponent({ + config: { ...mockCrmContactToken, initialContacts: mockCrmContacts }, + value: { data: `${getIdFromGraphQLId(contact.id)}` }, + }); + + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + + expect(tokenSegments).toHaveLength(3); // Contact, =, Contact name + expect(tokenSegments.at(2).text()).toBe(`${contact.firstName} ${contact.lastName}`); // Contact name + }); + + it('renders provided defaultContacts as suggestions', async () => { + mountComponent({ + active: true, + config: { ...mockCrmContactToken, defaultContacts }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await nextTick(); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(defaultContacts.length); + defaultContacts.forEach((contact, index) => { + expect(suggestions.at(index).text()).toBe(contact.text); + }); + }); + + it('does not render divider when no defaultContacts', async () => { + mountComponent({ + active: true, + config: { ...mockCrmContactToken, defaultContacts: [] }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await nextTick(); + + expect(wrapper.find(GlFilteredSearchSuggestion).exists()).toBe(false); + expect(wrapper.find(GlDropdownDivider).exists()).toBe(false); + }); + + it('renders `DEFAULT_NONE_ANY` as default suggestions', () => { + mountComponent({ + active: true, + config: { ...mockCrmContactToken }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(DEFAULT_NONE_ANY.length); + DEFAULT_NONE_ANY.forEach((contact, index) => { + expect(suggestions.at(index).text()).toBe(contact.text); + }); + }); + + it('emits listeners in the base-token', () => { + const mockInput = jest.fn(); + mountComponent({ + listeners: { + input: mockInput, + }, + }); + wrapper.findComponent(BaseToken).vm.$emit('input', [{ data: 'mockData', operator: '=' }]); + + expect(mockInput).toHaveBeenLastCalledWith([{ data: 'mockData', operator: '=' }]); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js new file mode 100644 index 00000000000..977f8bbef61 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js @@ -0,0 +1,282 @@ +import { + GlFilteredSearchSuggestion, + GlFilteredSearchTokenSegment, + GlDropdownDivider, +} from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import Vue, { 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 { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import CrmOrganizationToken from '~/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue'; +import searchCrmOrganizationsQuery from '~/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql'; + +import { + mockCrmOrganizations, + mockCrmOrganizationToken, + mockGroupCrmOrganizationsQueryResponse, + mockProjectCrmOrganizationsQueryResponse, +} from '../mock_data'; + +jest.mock('~/flash'); + +const defaultStubs = { + Portal: true, + BaseToken, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, +}; + +describe('CrmOrganizationToken', () => { + Vue.use(VueApollo); + + let wrapper; + let fakeApollo; + + const getBaseToken = () => wrapper.findComponent(BaseToken); + + const searchGroupCrmOrganizationsQueryHandler = jest + .fn() + .mockResolvedValue(mockGroupCrmOrganizationsQueryResponse); + const searchProjectCrmOrganizationsQueryHandler = jest + .fn() + .mockResolvedValue(mockProjectCrmOrganizationsQueryResponse); + + const mountComponent = ({ + config = mockCrmOrganizationToken, + value = { data: '' }, + active = false, + stubs = defaultStubs, + listeners = {}, + queryHandler = searchGroupCrmOrganizationsQueryHandler, + } = {}) => { + fakeApollo = createMockApollo([[searchCrmOrganizationsQuery, queryHandler]]); + wrapper = mount(CrmOrganizationToken, { + propsData: { + config, + value, + active, + cursorPosition: 'start', + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: () => 'custom-class', + }, + stubs, + listeners, + apolloProvider: fakeApollo, + }); + }; + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + describe('methods', () => { + describe('fetchOrganizations', () => { + describe('for groups', () => { + beforeEach(() => { + mountComponent(); + }); + + it('calls the apollo query providing the searchString when search term is a string', async () => { + getBaseToken().vm.$emit('fetch-suggestions', 'foo'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchGroupCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'group', + isProject: false, + searchString: 'foo', + searchIds: null, + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmOrganizations); + }); + + it('calls the apollo query providing the searchId when search term is a number', async () => { + getBaseToken().vm.$emit('fetch-suggestions', '5'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchGroupCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'group', + isProject: false, + searchString: null, + searchIds: ['gid://gitlab/CustomerRelations::Organization/5'], + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmOrganizations); + }); + }); + + describe('for projects', () => { + beforeEach(() => { + mountComponent({ + config: { + fullPath: 'project', + isProject: true, + }, + queryHandler: searchProjectCrmOrganizationsQueryHandler, + }); + }); + + it('calls the apollo query providing the searchString when search term is a string', async () => { + getBaseToken().vm.$emit('fetch-suggestions', 'foo'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchProjectCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'project', + isProject: true, + searchString: 'foo', + searchIds: null, + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmOrganizations); + }); + + it('calls the apollo query providing the searchId when search term is a number', async () => { + getBaseToken().vm.$emit('fetch-suggestions', '5'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchProjectCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'project', + isProject: true, + searchString: null, + searchIds: ['gid://gitlab/CustomerRelations::Organization/5'], + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmOrganizations); + }); + }); + + it('calls `createFlash` with flash error message when request fails', async () => { + mountComponent(); + + jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); + + getBaseToken().vm.$emit('fetch-suggestions'); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem fetching CRM organizations.', + }); + }); + + it('sets `loading` to false when request completes', async () => { + mountComponent(); + + jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); + + getBaseToken().vm.$emit('fetch-suggestions'); + + await waitForPromises(); + + expect(getBaseToken().props('suggestionsLoading')).toBe(false); + }); + }); + }); + + describe('template', () => { + const defaultOrganizations = DEFAULT_NONE_ANY; + + it('renders base-token component', () => { + mountComponent({ + config: { ...mockCrmOrganizationToken, initialOrganizations: mockCrmOrganizations }, + value: { data: '1' }, + }); + + const baseTokenEl = wrapper.find(BaseToken); + + expect(baseTokenEl.exists()).toBe(true); + expect(baseTokenEl.props()).toMatchObject({ + suggestions: mockCrmOrganizations, + getActiveTokenValue: wrapper.vm.getActiveOrganization, + }); + }); + + it.each(mockCrmOrganizations)('renders token item when value is selected', (organization) => { + mountComponent({ + config: { ...mockCrmOrganizationToken, initialOrganizations: mockCrmOrganizations }, + value: { data: `${getIdFromGraphQLId(organization.id)}` }, + }); + + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + + expect(tokenSegments).toHaveLength(3); // Organization, =, Organization name + expect(tokenSegments.at(2).text()).toBe(organization.name); // Organization name + }); + + it('renders provided defaultOrganizations as suggestions', async () => { + mountComponent({ + active: true, + config: { ...mockCrmOrganizationToken, defaultOrganizations }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await nextTick(); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(defaultOrganizations.length); + defaultOrganizations.forEach((organization, index) => { + expect(suggestions.at(index).text()).toBe(organization.text); + }); + }); + + it('does not render divider when no defaultOrganizations', async () => { + mountComponent({ + active: true, + config: { ...mockCrmOrganizationToken, defaultOrganizations: [] }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await nextTick(); + + expect(wrapper.find(GlFilteredSearchSuggestion).exists()).toBe(false); + expect(wrapper.find(GlDropdownDivider).exists()).toBe(false); + }); + + it('renders `DEFAULT_NONE_ANY` as default suggestions', () => { + mountComponent({ + active: true, + config: { ...mockCrmOrganizationToken }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(DEFAULT_NONE_ANY.length); + DEFAULT_NONE_ANY.forEach((organization, index) => { + expect(suggestions.at(index).text()).toBe(organization.text); + }); + }); + + it('emits listeners in the base-token', () => { + const mockInput = jest.fn(); + mountComponent({ + listeners: { + input: mockInput, + }, + }); + wrapper.findComponent(BaseToken).vm.$emit('input', [{ data: 'mockData', operator: '=' }]); + + expect(mockInput).toHaveBeenLastCalledWith([{ data: 'mockData', operator: '=' }]); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js index b3376f26a25..85a135d2b89 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -67,11 +67,6 @@ describe('Markdown field component', () => { enablePreview, restrictedToolBarItems, }, - provide: { - glFeatures: { - contactsAutocomplete: true, - }, - }, }, ); } diff --git a/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap b/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap index 5e956d66b6a..bf6c8e8c704 100644 --- a/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap +++ b/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap @@ -7,16 +7,19 @@ exports[`Issue placeholder note component matches snapshot 1`] = ` <div class="timeline-icon" > - <user-avatar-link-stub - imgalt="" - imgcssclasses="" - imgsize="40" - imgsrc="mock_path" - linkhref="/root" - tooltipplacement="top" - tooltiptext="" - username="" - /> + <gl-avatar-link-stub + class="gl-mr-3" + href="/root" + > + <gl-avatar-stub + alt="Root" + entityid="0" + entityname="root" + shape="circle" + size="[object Object]" + src="mock_path" + /> + </gl-avatar-link-stub> </div> <div diff --git a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js index 6881cb79740..f951cfd5cd9 100644 --- a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; +import { GlAvatar } from '@gitlab/ui'; import Vue from 'vue'; import Vuex from 'vuex'; import IssuePlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import { userDataMock } from 'jest/notes/mock_data'; Vue.use(Vuex); @@ -56,14 +56,14 @@ describe('Issue placeholder note component', () => { describe('avatar size', () => { it.each` - size | line | isOverviewTab - ${40} | ${null} | ${false} - ${24} | ${{ line_code: '123' }} | ${false} - ${40} | ${{ line_code: '123' }} | ${true} + size | line | isOverviewTab + ${{ default: 24, md: 32 }} | ${null} | ${false} + ${24} | ${{ line_code: '123' }} | ${false} + ${{ default: 24, md: 32 }} | ${{ line_code: '123' }} | ${true} `('renders avatar $size for $line and $isOverviewTab', ({ size, line, isOverviewTab }) => { createComponent(false, { line, isOverviewTab }); - expect(wrapper.findComponent(UserAvatarLink).props('imgSize')).toBe(size); + expect(wrapper.findComponent(GlAvatar).props('size')).toEqual(size); }); }); }); diff --git a/spec/frontend/vue_shared/components/page_size_selector_spec.js b/spec/frontend/vue_shared/components/page_size_selector_spec.js new file mode 100644 index 00000000000..5ec0b863afd --- /dev/null +++ b/spec/frontend/vue_shared/components/page_size_selector_spec.js @@ -0,0 +1,44 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import PageSizeSelector, { PAGE_SIZES } from '~/vue_shared/components/page_size_selector.vue'; + +describe('Page size selector component', () => { + let wrapper; + + const createWrapper = ({ pageSize = 20 } = {}) => { + wrapper = shallowMount(PageSizeSelector, { + propsData: { value: pageSize }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + + afterEach(() => { + wrapper.destroy(); + }); + + it.each(PAGE_SIZES)('shows expected text in the dropdown button for page size %s', (pageSize) => { + createWrapper({ pageSize }); + + expect(findDropdown().props('text')).toBe(`Show ${pageSize} items`); + }); + + it('shows the expected dropdown items', () => { + createWrapper(); + + PAGE_SIZES.forEach((pageSize, index) => { + expect(findDropdownItems().at(index).text()).toBe(`Show ${pageSize} items`); + }); + }); + + it('will emit the new page size when a dropdown item is clicked', () => { + createWrapper(); + + findDropdownItems().wrappers.forEach((itemWrapper, index) => { + itemWrapper.vm.$emit('click'); + + expect(wrapper.emitted('input')[index][0]).toBe(PAGE_SIZES[index]); + }); + }); +}); 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 8270ff31574..51a936c0509 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 @@ -195,7 +195,7 @@ describe('AlertManagementEmptyState', () => { tabs.forEach((tab, i) => { const status = ITEMS_STATUS_TABS[i].status.toLowerCase(); expect(tab.attributes('data-testid')).toContain(ITEMS_STATUS_TABS[i].status); - expect(badges.at(i).text()).toContain(itemsCount[status]); + expect(badges.at(i).text()).toContain(itemsCount[status].toString()); }); }); }); diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap b/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap index 8ff49271eb5..2ea8985b16a 100644 --- a/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap +++ b/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap @@ -42,6 +42,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = ` > <gl-accordion-item-stub class="gl-font-weight-normal" + headerclass="" title="More Details" titlevisible="Less Details" > @@ -76,6 +77,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = ` > <gl-accordion-item-stub class="gl-font-weight-normal" + headerclass="" title="More Details" titlevisible="Less Details" > @@ -110,6 +112,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = ` > <gl-accordion-item-stub class="gl-font-weight-normal" + headerclass="" title="More Details" titlevisible="Less Details" > @@ -144,6 +147,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = ` > <gl-accordion-item-stub class="gl-font-weight-normal" + headerclass="" title="More Details" titlevisible="Less Details" > diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js index 7173abe1316..a38dcd626f4 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js +++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js @@ -79,7 +79,7 @@ describe('RunnerInstructionsModal component', () => { } }; - beforeEach(async () => { + beforeEach(() => { runnerPlatformsHandler = jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms); runnerSetupInstructionsHandler = jest.fn().mockResolvedValue(mockGraphqlInstructions); }); @@ -259,11 +259,11 @@ describe('RunnerInstructionsModal component', () => { }); describe('when apollo is loading', () => { - beforeEach(() => { + it('should show a skeleton loader', async () => { createComponent(); - }); + await nextTick(); + await nextTick(); - it('should show a skeleton loader', async () => { expect(findSkeletonLoader().exists()).toBe(true); expect(findGlLoadingIcon().exists()).toBe(false); @@ -275,6 +275,8 @@ describe('RunnerInstructionsModal component', () => { }); it('once loaded, should not show a loading state', async () => { + createComponent(); + await waitForPromises(); expect(findSkeletonLoader().exists()).toBe(false); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/link_dependencies_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/link_dependencies_spec.js new file mode 100644 index 00000000000..3036ce43888 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/link_dependencies_spec.js @@ -0,0 +1,14 @@ +import packageJsonLinker from '~/vue_shared/components/source_viewer/plugins/utils/package_json_linker'; +import linkDependencies from '~/vue_shared/components/source_viewer/plugins/link_dependencies'; +import { PACKAGE_JSON_FILE_TYPE, PACKAGE_JSON_CONTENT } from './mock_data'; + +jest.mock('~/vue_shared/components/source_viewer/plugins/utils/package_json_linker'); + +describe('Highlight.js plugin for linking dependencies', () => { + const hljsResultMock = { value: 'test' }; + + it('calls packageJsonLinker for package_json file types', () => { + linkDependencies(hljsResultMock, PACKAGE_JSON_FILE_TYPE, PACKAGE_JSON_CONTENT); + expect(packageJsonLinker).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/mock_data.js b/spec/frontend/vue_shared/components/source_viewer/plugins/mock_data.js new file mode 100644 index 00000000000..75659770e2c --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/mock_data.js @@ -0,0 +1,2 @@ +export const PACKAGE_JSON_FILE_TYPE = 'package_json'; +export const PACKAGE_JSON_CONTENT = '{ "dependencies": { "@babel/core": "^7.18.5" } }'; diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util_spec.js new file mode 100644 index 00000000000..ee200747af9 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util_spec.js @@ -0,0 +1,33 @@ +import { + createLink, + generateHLJSOpenTag, +} from '~/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util'; + +describe('createLink', () => { + it('generates a link with the correct attributes', () => { + const href = 'http://test.com'; + const innerText = 'testing'; + const result = `<a href="${href}" rel="nofollow noreferrer noopener">${innerText}</a>`; + + expect(createLink(href, innerText)).toBe(result); + }); + + it('escapes the user-controlled content', () => { + const unescapedXSS = '<script>XSS</script>'; + const escapedXSS = '&lt;script&gt;XSS&lt;/script&gt;'; + const href = `http://test.com/${unescapedXSS}`; + const innerText = `testing${unescapedXSS}`; + const result = `<a href="http://test.com/${escapedXSS}" rel="nofollow noreferrer noopener">testing${escapedXSS}</a>`; + + expect(createLink(href, innerText)).toBe(result); + }); +}); + +describe('generateHLJSOpenTag', () => { + it('generates an open tag with the correct selector', () => { + const type = 'string'; + const result = `<span class="hljs-${type}">"`; + + expect(generateHLJSOpenTag(type)).toBe(result); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/utils/package_json_linker_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/package_json_linker_spec.js new file mode 100644 index 00000000000..e83c129818c --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/package_json_linker_spec.js @@ -0,0 +1,15 @@ +import packageJsonLinker from '~/vue_shared/components/source_viewer/plugins/utils/package_json_linker'; +import { PACKAGE_JSON_CONTENT } from '../mock_data'; + +describe('Highlight.js plugin for linking package.json dependencies', () => { + it('mutates the input value by wrapping dependency names and versions in anchors', () => { + const inputValue = + '<span class="hljs-attr">"@babel/core"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"^7.18.5"</span>'; + const outputValue = + '<span class="hljs-attr">"<a href="https://npmjs.com/package/@babel/core" rel="nofollow noreferrer noopener">@babel/core</a>"</span>: <span class="hljs-attr">"<a href="https://npmjs.com/package/@babel/core" rel="nofollow noreferrer noopener">^7.18.5</a>"</span>'; + const hljsResultMock = { value: inputValue }; + + const output = packageJsonLinker(hljsResultMock, PACKAGE_JSON_CONTENT); + expect(output).toBe(outputValue); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js index bb0945a1f3e..2c03b7aa7d3 100644 --- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js @@ -5,10 +5,16 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue'; import { registerPlugins } from '~/vue_shared/components/source_viewer/plugins/index'; import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue'; -import { ROUGE_TO_HLJS_LANGUAGE_MAP } from '~/vue_shared/components/source_viewer/constants'; +import { + EVENT_ACTION, + EVENT_LABEL_VIEWER, + EVENT_LABEL_FALLBACK, + ROUGE_TO_HLJS_LANGUAGE_MAP, +} from '~/vue_shared/components/source_viewer/constants'; import waitForPromises from 'helpers/wait_for_promises'; import LineHighlighter from '~/blob/line_highlighter'; import eventHub from '~/notes/event_hub'; +import Tracking from '~/tracking'; jest.mock('~/blob/line_highlighter'); jest.mock('highlight.js/lib/core'); @@ -34,7 +40,8 @@ describe('Source Viewer component', () => { const chunk2 = generateContent('// Some source code 2', 70); const content = chunk1 + chunk2; const path = 'some/path.js'; - const DEFAULT_BLOB_DATA = { language, rawTextBlob: content, path }; + const fileType = 'javascript'; + const DEFAULT_BLOB_DATA = { language, rawTextBlob: content, path, fileType }; const highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`; const createComponent = async (blob = {}) => { @@ -52,17 +59,38 @@ describe('Source Viewer component', () => { hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent })); jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately); jest.spyOn(eventHub, '$emit'); + jest.spyOn(Tracking, 'event'); return createComponent(); }); afterEach(() => wrapper.destroy()); + describe('event tracking', () => { + it('fires a tracking event when the component is created', () => { + const eventData = { label: EVENT_LABEL_VIEWER, property: language }; + expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); + }); + + it('does not emit an error event when the language is supported', () => { + expect(wrapper.emitted('error')).toBeUndefined(); + }); + + it('fires a tracking event and emits an error when the language is not supported', () => { + const unsupportedLanguage = 'apex'; + const eventData = { label: EVENT_LABEL_FALLBACK, property: unsupportedLanguage }; + createComponent({ language: unsupportedLanguage }); + + expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); + expect(wrapper.emitted('error')).toHaveLength(1); + }); + }); + describe('highlight.js', () => { beforeEach(() => createComponent({ language: mappedLanguage })); it('registers our plugins for Highlight.js', () => { - expect(registerPlugins).toHaveBeenCalledWith(hljs); + expect(registerPlugins).toHaveBeenCalledWith(hljs, fileType, content); }); it('registers the language definition', async () => { @@ -74,6 +102,13 @@ describe('Source Viewer component', () => { ); }); + it('registers json language definition if fileType is package_json', async () => { + await createComponent({ language: 'json', fileType: 'package_json' }); + const languageDefinition = await import(`highlight.js/lib/languages/json`); + + expect(hljs.registerLanguage).toHaveBeenCalledWith('json', languageDefinition.default); + }); + it('highlights the first chunk', () => { expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage }); }); diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js index a54f3450633..9550368eefc 100644 --- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js +++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js @@ -2,7 +2,6 @@ import { GlSkeletonLoader, GlIcon } from '@gitlab/ui'; import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { AVAILABILITY_STATUS } from '~/set_status_modal/utils'; -import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue'; import UserPopover from '~/vue_shared/components/user_popover/user_popover.vue'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; @@ -48,7 +47,6 @@ describe('User Popover Component', () => { const findUserStatus = () => wrapper.findByTestId('user-popover-status'); const findTarget = () => document.querySelector('.js-user-link'); - const findUserName = () => wrapper.find(UserNameWithStatus); const findSecurityBotDocsLink = () => wrapper.findByTestId('user-popover-bot-docs-link'); const findUserLocalTime = () => wrapper.findByTestId('user-popover-local-time'); const findToggleFollowButton = () => wrapper.findByTestId('toggle-follow-button'); @@ -245,9 +243,7 @@ describe('User Popover Component', () => { createWrapper({ user }); - expect(findUserName().exists()).toBe(true); - expect(wrapper.text()).toContain(user.name); - expect(wrapper.text()).toContain('(Busy)'); + expect(wrapper.findByText('(Busy)').exists()).toBe(true); }); it('should hide the busy status for any other status', () => { @@ -258,13 +254,32 @@ describe('User Popover Component', () => { createWrapper({ user }); - expect(wrapper.text()).not.toContain('(Busy)'); + expect(wrapper.findByText('(Busy)').exists()).toBe(false); }); - it('passes `pronouns` prop to `UserNameWithStatus` component', () => { + it('shows pronouns when user has them set', () => { createWrapper(); - expect(findUserName().props('pronouns')).toBe('they/them'); + expect(wrapper.findByText('(they/them)').exists()).toBe(true); + }); + + describe.each` + pronouns + ${undefined} + ${null} + ${''} + ${' '} + `('when pronouns are set to $pronouns', ({ pronouns }) => { + it('does not render pronouns', () => { + const user = { + ...DEFAULT_PROPS.user, + pronouns, + }; + + createWrapper({ user }); + + expect(wrapper.findByTestId('user-popover-pronouns').exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js index 70017903079..80f14dffd08 100644 --- a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js @@ -39,6 +39,8 @@ describe('IssuableItem', () => { const originalUrl = gon.gitlab_url; let wrapper; + const findTimestampWrapper = () => wrapper.find('[data-testid="issuable-timestamp"]'); + beforeEach(() => { gon.gitlab_url = MOCK_GITLAB_URL; }); @@ -150,12 +152,54 @@ describe('IssuableItem', () => { }); }); - describe('updatedAt', () => { - it('returns string containing timeago string based on `issuable.updatedAt`', () => { + describe('timestamp', () => { + it('returns timestamp based on `issuable.updatedAt` when the issue is open', () => { wrapper = createComponent(); - expect(wrapper.vm.updatedAt).toContain('updated'); - expect(wrapper.vm.updatedAt).toContain('ago'); + expect(findTimestampWrapper().attributes('title')).toBe('Sep 10, 2020 11:41am UTC'); + }); + + it('returns timestamp based on `issuable.closedAt` when the issue is closed', () => { + wrapper = createComponent({ + issuable: { ...mockIssuable, closedAt: '2020-06-18T11:30:00Z', state: 'closed' }, + }); + + expect(findTimestampWrapper().attributes('title')).toBe('Jun 18, 2020 11:30am UTC'); + }); + + it('returns timestamp based on `issuable.updatedAt` when the issue is closed but `issuable.closedAt` is undefined', () => { + wrapper = createComponent({ + issuable: { ...mockIssuable, closedAt: undefined, state: 'closed' }, + }); + + expect(findTimestampWrapper().attributes('title')).toBe('Sep 10, 2020 11:41am UTC'); + }); + }); + + describe('formattedTimestamp', () => { + it('returns timeago string based on `issuable.updatedAt` when the issue is open', () => { + wrapper = createComponent(); + + expect(findTimestampWrapper().text()).toContain('updated'); + expect(findTimestampWrapper().text()).toContain('ago'); + }); + + it('returns timeago string based on `issuable.closedAt` when the issue is closed', () => { + wrapper = createComponent({ + issuable: { ...mockIssuable, closedAt: '2020-06-18T11:30:00Z', state: 'closed' }, + }); + + expect(findTimestampWrapper().text()).toContain('closed'); + expect(findTimestampWrapper().text()).toContain('ago'); + }); + + it('returns timeago string based on `issuable.updatedAt` when the issue is closed but `issuable.closedAt` is undefined', () => { + wrapper = createComponent({ + issuable: { ...mockIssuable, closedAt: undefined, state: 'closed' }, + }); + + expect(findTimestampWrapper().text()).toContain('updated'); + expect(findTimestampWrapper().text()).toContain('ago'); }); }); @@ -456,18 +500,31 @@ describe('IssuableItem', () => { it('renders issuable updatedAt info', () => { wrapper = createComponent(); - const updatedAtEl = wrapper.find('[data-testid="issuable-updated-at"]'); + const timestampEl = wrapper.find('[data-testid="issuable-timestamp"]'); - expect(updatedAtEl.attributes('title')).toBe('Sep 10, 2020 11:41am UTC'); - expect(updatedAtEl.text()).toBe(wrapper.vm.updatedAt); + expect(timestampEl.attributes('title')).toBe('Sep 10, 2020 11:41am UTC'); + expect(timestampEl.text()).toBe(wrapper.vm.formattedTimestamp); }); describe('when issuable is closed', () => { it('renders issuable card with a closed style', () => { - wrapper = createComponent({ issuable: { ...mockIssuable, closedAt: '2020-12-10' } }); + wrapper = createComponent({ + issuable: { ...mockIssuable, closedAt: '2020-12-10', state: 'closed' }, + }); expect(wrapper.classes()).toContain('closed'); }); + + it('renders issuable closedAt info and does not render updatedAt info', () => { + wrapper = createComponent({ + issuable: { ...mockIssuable, closedAt: '2022-06-18T11:30:00Z', state: 'closed' }, + }); + + const timestampEl = wrapper.find('[data-testid="issuable-timestamp"]'); + + expect(timestampEl.attributes('title')).toBe('Jun 18, 2022 11:30am UTC'); + expect(timestampEl.text()).toBe(wrapper.vm.formattedTimestamp); + }); }); describe('when issuable was created within the past 24 hours', () => { 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 66f71c0b028..50e79dbe589 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 @@ -9,6 +9,7 @@ import IssuableItem from '~/vue_shared/issuable/list/components/issuable_item.vu import IssuableListRoot from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import IssuableTabs from '~/vue_shared/issuable/list/components/issuable_tabs.vue'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import PageSizeSelector from '~/vue_shared/components/page_size_selector.vue'; import { mockIssuableListProps, mockIssuables } from '../mock_data'; @@ -44,6 +45,7 @@ describe('IssuableListRoot', () => { const findIssuableItem = () => wrapper.findComponent(IssuableItem); const findIssuableTabs = () => wrapper.findComponent(IssuableTabs); const findVueDraggable = () => wrapper.findComponent(VueDraggable); + const findPageSizeSelector = () => wrapper.findComponent(PageSizeSelector); afterEach(() => { wrapper.destroy(); @@ -292,6 +294,7 @@ describe('IssuableListRoot', () => { }); expect(findGlKeysetPagination().exists()).toBe(false); + expect(findPageSizeSelector().exists()).toBe(false); expect(findGlPagination().props()).toMatchObject({ perPage: 20, value: 1, @@ -483,4 +486,24 @@ describe('IssuableListRoot', () => { }); }); }); + + describe('page size selector', () => { + beforeEach(() => { + wrapper = createComponent({ + props: { + showPageSizeChangeControls: true, + }, + }); + }); + + it('has the page size change component', async () => { + expect(findPageSizeSelector().exists()).toBe(true); + }); + + it('emits "page-size-change" event when its input is changed', () => { + const pageSize = 123; + findPageSizeSelector().vm.$emit('input', pageSize); + expect(wrapper.emitted('page-size-change')).toEqual([[pageSize]]); + }); + }); }); 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 5aa67667033..6f62fb77353 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 @@ -70,7 +70,7 @@ describe('IssuableTitle', () => { expect(titleEl.exists()).toBe(true); expect(titleEl.html()).toBe( - '<h1 dir="auto" data-testid="title" class="title qa-title gl-font-size-h-display"><b>Sample</b> title</h1>', + '<h1 dir="auto" data-qa-selector="title_content" data-testid="title" class="title gl-font-size-h-display"><b>Sample</b> title</h1>', ); wrapperWithTitle.destroy(); diff --git a/spec/frontend/work_items/components/item_title_spec.js b/spec/frontend/work_items/components/item_title_spec.js index 2c3f6ef8634..a55f448c9a2 100644 --- a/spec/frontend/work_items/components/item_title_spec.js +++ b/spec/frontend/work_items/components/item_title_spec.js @@ -1,5 +1,4 @@ import { shallowMount } from '@vue/test-utils'; -import { escape } from 'lodash'; import ItemTitle from '~/work_items/components/item_title.vue'; jest.mock('lodash/escape', () => jest.fn((fn) => fn)); @@ -51,6 +50,5 @@ describe('ItemTitle', () => { await findInputEl().trigger(sourceEvent); expect(wrapper.emitted(eventName)).toBeTruthy(); - expect(escape).toHaveBeenCalledWith(mockUpdatedTitle); }); }); diff --git a/spec/frontend/work_items/components/work_item_assignees_spec.js b/spec/frontend/work_items/components/work_item_assignees_spec.js index 0552fe5050e..299949a4baa 100644 --- a/spec/frontend/work_items/components/work_item_assignees_spec.js +++ b/spec/frontend/work_items/components/work_item_assignees_spec.js @@ -1,52 +1,90 @@ -import { GlLink, GlTokenSelector } from '@gitlab/ui'; -import { nextTick } from 'vue'; +import { GlLink, GlTokenSelector, GlSkeletonLoader } from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking } from 'helpers/tracking_helper'; +import { stripTypenames } from 'helpers/graphql_helpers'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; +import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql'; +import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; -import localUpdateWorkItemMutation from '~/work_items/graphql/local_update_work_item.mutation.graphql'; - -const mockAssignees = [ - { - __typename: 'UserCore', - id: 'gid://gitlab/User/1', - avatarUrl: '', - webUrl: '', - name: 'John Doe', - username: 'doe_I', - }, - { - __typename: 'UserCore', - id: 'gid://gitlab/User/2', - avatarUrl: '', - webUrl: '', - name: 'Marcus Rutherford', - username: 'ruthfull', - }, -]; +import { i18n, TASK_TYPE_NAME, TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; +import { temporaryConfig, resolvers } from '~/work_items/graphql/provider'; +import { + projectMembersResponseWithCurrentUser, + mockAssignees, + workItemQueryResponse, + currentUserResponse, + currentUserNullResponse, + projectMembersResponseWithoutCurrentUser, +} from '../mock_data'; -const workItemId = 'gid://gitlab/WorkItem/1'; +Vue.use(VueApollo); -const mutate = jest.fn(); +const workItemId = 'gid://gitlab/WorkItem/1'; +const dropdownItems = projectMembersResponseWithCurrentUser.data.workspace.users.nodes; describe('WorkItemAssignees component', () => { let wrapper; const findAssigneeLinks = () => wrapper.findAllComponents(GlLink); const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const findEmptyState = () => wrapper.findByTestId('empty-state'); + const findAssignSelfButton = () => wrapper.findByTestId('assign-self'); + const findAssigneesTitle = () => wrapper.findByTestId('assignees-title'); + + const successSearchQueryHandler = jest + .fn() + .mockResolvedValue(projectMembersResponseWithCurrentUser); + const successCurrentUserQueryHandler = jest.fn().mockResolvedValue(currentUserResponse); + const noCurrentUserQueryHandler = jest.fn().mockResolvedValue(currentUserNullResponse); + + const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); + + const createComponent = ({ + assignees = mockAssignees, + searchQueryHandler = successSearchQueryHandler, + currentUserQueryHandler = successCurrentUserQueryHandler, + allowsMultipleAssignees = true, + canUpdate = true, + } = {}) => { + const apolloProvider = createMockApollo( + [ + [userSearchQuery, searchQueryHandler], + [currentUserQuery, currentUserQueryHandler], + ], + resolvers, + { + typePolicies: temporaryConfig.cacheConfig.typePolicies, + }, + ); + + apolloProvider.clients.defaultClient.writeQuery({ + query: workItemQuery, + variables: { + id: workItemId, + }, + data: workItemQueryResponse.data, + }); - const createComponent = ({ assignees = mockAssignees } = {}) => { wrapper = mountExtended(WorkItemAssignees, { + provide: { + fullPath: 'test-project-path', + }, propsData: { assignees, workItemId, - }, - mocks: { - $apollo: { - mutate, - }, + allowsMultipleAssignees, + workItemType: TASK_TYPE_NAME, + canUpdate, }, attachTo: document.body, + apolloProvider, }); }; @@ -54,39 +92,316 @@ describe('WorkItemAssignees component', () => { wrapper.destroy(); }); - it('should pass the correct data-user-id attribute', () => { + it('passes the correct data-user-id attribute', () => { createComponent(); expect(findAssigneeLinks().at(0).attributes('data-user-id')).toBe('1'); }); - describe('when there are assignees', () => { + it('container does not have shadow by default', () => { + createComponent(); + expect(findTokenSelector().props('containerClass')).toBe('gl-shadow-none!'); + }); + + it('container has shadow after focusing token selector', async () => { + createComponent(); + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findTokenSelector().props('containerClass')).toBe(''); + }); + + it('focuses token selector on token selector input event', async () => { + createComponent(); + findTokenSelector().vm.$emit('input', [mockAssignees[0]]); + await nextTick(); + + expect(findEmptyState().exists()).toBe(false); + expect(findTokenSelector().element.contains(document.activeElement)).toBe(true); + }); + + it('calls a mutation on clicking outside the token selector', async () => { + createComponent(); + findTokenSelector().vm.$emit('input', [mockAssignees[0]]); + findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); + await waitForPromises(); + + expect(findTokenSelector().props('selectedTokens')).toEqual([mockAssignees[0]]); + }); + + it('passes `false` to `viewOnly` token selector prop if user can update assignees', () => { + createComponent(); + + expect(findTokenSelector().props('viewOnly')).toBe(false); + }); + + it('passes `true` to `viewOnly` token selector prop if user can not update assignees', () => { + createComponent({ canUpdate: false }); + + expect(findTokenSelector().props('viewOnly')).toBe(true); + }); + + describe('when searching for users', () => { beforeEach(() => { createComponent(); }); - it('should focus token selector on token removal', async () => { - findTokenSelector().vm.$emit('token-remove', mockAssignees[0].id); + it('does not start user search by default', () => { + expect(findTokenSelector().props('loading')).toBe(false); + expect(findTokenSelector().props('dropdownItems')).toEqual([]); + }); + + it('starts user search on hovering for more than 250ms', async () => { + findTokenSelector().trigger('mouseover'); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); await nextTick(); - expect(findEmptyState().exists()).toBe(false); - expect(findTokenSelector().element.contains(document.activeElement)).toBe(true); + expect(findTokenSelector().props('loading')).toBe(true); }); - it('should call a mutation on clicking outside the token selector', async () => { - findTokenSelector().vm.$emit('input', [mockAssignees[0]]); - findTokenSelector().vm.$emit('token-remove'); + it('starts user search on focusing token selector', async () => { + findTokenSelector().vm.$emit('focus'); await nextTick(); - expect(mutate).not.toHaveBeenCalled(); - findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); + expect(findTokenSelector().props('loading')).toBe(true); + }); + + it('does not start searching if token-selector was hovered for less than 250ms', async () => { + findTokenSelector().trigger('mouseover'); + jest.advanceTimersByTime(100); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(false); + }); + + it('does not start searching if cursor was moved out from token selector before 250ms passed', async () => { + findTokenSelector().trigger('mouseover'); + jest.advanceTimersByTime(100); + + findTokenSelector().trigger('mouseout'); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(false); + }); + + it('shows skeleton loader on dropdown when loading users', async () => { + findTokenSelector().vm.$emit('focus'); await nextTick(); - expect(mutate).toHaveBeenCalledWith({ - mutation: localUpdateWorkItemMutation, - variables: { - input: { id: workItemId, assigneeIds: [mockAssignees[0].id] }, - }, + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('shows correct users list in dropdown when loaded', async () => { + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + + await waitForPromises(); + + expect(findSkeletonLoader().exists()).toBe(false); + expect(findTokenSelector().props('dropdownItems')).toHaveLength(2); + }); + + it('should search for users with correct key after text input', async () => { + const searchKey = 'Hello'; + + findTokenSelector().vm.$emit('focus'); + findTokenSelector().vm.$emit('text-input', searchKey); + await waitForPromises(); + + expect(successSearchQueryHandler).toHaveBeenCalledWith( + expect.objectContaining({ search: searchKey }), + ); + }); + }); + + it('emits error event if search users query fails', async () => { + createComponent({ searchQueryHandler: errorHandler }); + findTokenSelector().vm.$emit('focus'); + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[i18n.fetchError]]); + }); + + describe('when assigning to current user', () => { + it('does not show `Assign myself` button if current user is loading', () => { + createComponent(); + findTokenSelector().trigger('mouseover'); + + expect(findAssignSelfButton().exists()).toBe(false); + }); + + it('does not show `Assign myself` button if work item has assignees', async () => { + createComponent(); + await waitForPromises(); + findTokenSelector().trigger('mouseover'); + + expect(findAssignSelfButton().exists()).toBe(false); + }); + + it('does now show `Assign myself` button if user is not logged in', async () => { + createComponent({ currentUserQueryHandler: noCurrentUserQueryHandler, assignees: [] }); + await waitForPromises(); + findTokenSelector().trigger('mouseover'); + + expect(findAssignSelfButton().exists()).toBe(false); + }); + }); + + describe('when user is logged in and there are no assignees', () => { + beforeEach(() => { + createComponent({ assignees: [] }); + return waitForPromises(); + }); + + it('renders `Assign myself` button', async () => { + findTokenSelector().trigger('mouseover'); + expect(findAssignSelfButton().exists()).toBe(true); + }); + + it('calls update work item assignees mutation with current user as a variable on button click', () => { + // TODO: replace this test as soon as we have a real mutation implemented + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockImplementation(jest.fn()); + + findTokenSelector().trigger('mouseover'); + findAssignSelfButton().vm.$emit('click', new MouseEvent('click')); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + assignees: [stripTypenames(currentUserResponse.data.currentUser)], + id: workItemId, + }, + }, + }), + ); + }); + }); + + it('moves current user to the top of dropdown items if user is a project member', async () => { + createComponent(); + await waitForPromises(); + + expect(findTokenSelector().props('dropdownItems')[0]).toEqual( + expect.objectContaining({ + ...stripTypenames(currentUserResponse.data.currentUser), + }), + ); + }); + + describe('when current user is not in the list of project members', () => { + const searchQueryHandler = jest + .fn() + .mockResolvedValue(projectMembersResponseWithoutCurrentUser); + + beforeEach(() => { + createComponent({ searchQueryHandler }); + return waitForPromises(); + }); + + it('adds current user to the top of dropdown items', () => { + expect(findTokenSelector().props('dropdownItems')[0]).toEqual( + stripTypenames(currentUserResponse.data.currentUser), + ); + }); + + it('does not add current user if search is not empty', async () => { + findTokenSelector().vm.$emit('text-input', 'test'); + await waitForPromises(); + + expect(findTokenSelector().props('dropdownItems')[0]).not.toEqual( + stripTypenames(currentUserResponse.data.currentUser), + ); + }); + }); + + it('has `Assignee` label when only one assignee is present', () => { + createComponent({ assignees: [mockAssignees[0]] }); + + expect(findAssigneesTitle().text()).toBe('Assignee'); + }); + + it('has `Assignees` label if more than one assignee is present', () => { + createComponent(); + + expect(findAssigneesTitle().text()).toBe('Assignees'); + }); + + describe('when multiple assignees are allowed', () => { + beforeEach(() => { + createComponent({ allowsMultipleAssignees: true, assignees: [] }); + return waitForPromises(); + }); + + it('has `Add assignees` text on placeholder', () => { + expect(findEmptyState().text()).toContain('Add assignees'); + }); + + it('adds multiple assignees when token-selector provides multiple values', async () => { + findTokenSelector().vm.$emit('input', dropdownItems); + await nextTick(); + + expect(findTokenSelector().props('selectedTokens')).toHaveLength(2); + }); + }); + + describe('when multiple assignees are not allowed', () => { + beforeEach(() => { + createComponent({ allowsMultipleAssignees: false, assignees: [] }); + return waitForPromises(); + }); + + it('has `Add assignee` text on placeholder', () => { + expect(findEmptyState().text()).toContain('Add assignee'); + expect(findEmptyState().text()).not.toContain('Add assignees'); + }); + + it('adds a single assignee token-selector provides multiple values', async () => { + findTokenSelector().vm.$emit('input', dropdownItems); + await nextTick(); + + expect(findTokenSelector().props('selectedTokens')).toHaveLength(1); + }); + + it('removes shadow after token-selector input', async () => { + findTokenSelector().vm.$emit('input', dropdownItems); + await nextTick(); + + expect(findTokenSelector().props('containerClass')).toBe('gl-shadow-none!'); + }); + }); + + describe('tracking', () => { + let trackingSpy; + + beforeEach(() => { + createComponent(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + trackingSpy = null; + }); + + it('does not track updating assignees until token selector blur event', async () => { + findTokenSelector().vm.$emit('input', [mockAssignees[0]]); + await waitForPromises(); + + expect(trackingSpy).not.toHaveBeenCalled(); + }); + + it('tracks editing the assignees on token selector blur', async () => { + findTokenSelector().vm.$emit('input', [mockAssignees[0]]); + findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_assignees', { + category: TRACKING_CATEGORY_SHOW, + label: 'item_assignees', + property: 'type_Task', }); }); }); diff --git a/spec/frontend/work_items/components/work_item_detail_modal_spec.js b/spec/frontend/work_items/components/work_item_detail_modal_spec.js index d55ba318e46..70b1261bdb7 100644 --- a/spec/frontend/work_items/components/work_item_detail_modal_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_modal_spec.js @@ -66,6 +66,7 @@ describe('WorkItemDetailModal component', () => { createComponent(); expect(findWorkItemDetail().props()).toEqual({ + isModal: true, workItemId: '1', workItemParentId: '2', }); @@ -98,6 +99,15 @@ describe('WorkItemDetailModal component', () => { expect(wrapper.emitted('close')).toBeTruthy(); }); + it('hides the modal when WorkItemDetail emits `close` event', () => { + createComponent(); + const closeSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide'); + + findWorkItemDetail().vm.$emit('close'); + + expect(closeSpy).toHaveBeenCalled(); + }); + describe('delete work item', () => { it('emits workItemDeleted and closes modal', async () => { createComponent(); diff --git a/spec/frontend/work_items/components/work_item_information_spec.js b/spec/frontend/work_items/components/work_item_information_spec.js new file mode 100644 index 00000000000..d5f6921c2bc --- /dev/null +++ b/spec/frontend/work_items/components/work_item_information_spec.js @@ -0,0 +1,48 @@ +import { mount } from '@vue/test-utils'; +import { GlAlert, GlLink } from '@gitlab/ui'; +import WorkItemInformation from '~/work_items/components/work_item_information.vue'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +const createComponent = () => mount(WorkItemInformation); + +describe('Work item information alert', () => { + let wrapper; + const tasksHelpPath = helpPagePath('user/tasks'); + const workItemsHelpPath = helpPagePath('development/work_items'); + + const findAlert = () => wrapper.findComponent(GlAlert); + const findHelpLink = () => wrapper.findComponent(GlLink); + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should be visible', () => { + expect(findAlert().exists()).toBe(true); + }); + + it('should emit `work-item-banner-dismissed` event when cross icon is clicked', () => { + findAlert().vm.$emit('dismiss'); + expect(wrapper.emitted('work-item-banner-dismissed').length).toBe(1); + }); + + it('the alert variant should be tip', () => { + expect(findAlert().props('variant')).toBe('tip'); + }); + + it('should have the correct text for primary button and link', () => { + expect(findAlert().props('title')).toBe(WorkItemInformation.i18n.tasksInformationTitle); + expect(findAlert().props('primaryButtonText')).toBe( + WorkItemInformation.i18n.learnTasksButtonText, + ); + expect(findAlert().props('primaryButtonLink')).toBe(tasksHelpPath); + }); + + it('should have the correct link to work item link', () => { + expect(findHelpLink().exists()).toBe(true); + expect(findHelpLink().attributes('href')).toBe(workItemsHelpPath); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_labels_spec.js b/spec/frontend/work_items/components/work_item_labels_spec.js new file mode 100644 index 00000000000..1734b901d1a --- /dev/null +++ b/spec/frontend/work_items/components/work_item_labels_spec.js @@ -0,0 +1,171 @@ +import { GlTokenSelector, GlSkeletonLoader } from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import labelSearchQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; +import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; +import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; +import { i18n } from '~/work_items/constants'; +import { temporaryConfig, resolvers } from '~/work_items/graphql/provider'; +import { projectLabelsResponse, mockLabels, workItemQueryResponse } from '../mock_data'; + +Vue.use(VueApollo); + +const workItemId = 'gid://gitlab/WorkItem/1'; + +describe('WorkItemLabels component', () => { + let wrapper; + + const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + + const findEmptyState = () => wrapper.findByTestId('empty-state'); + + const successSearchQueryHandler = jest.fn().mockResolvedValue(projectLabelsResponse); + const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); + + const createComponent = ({ + labels = mockLabels, + canUpdate = true, + searchQueryHandler = successSearchQueryHandler, + } = {}) => { + const apolloProvider = createMockApollo([[labelSearchQuery, searchQueryHandler]], resolvers, { + typePolicies: temporaryConfig.cacheConfig.typePolicies, + }); + + apolloProvider.clients.defaultClient.writeQuery({ + query: workItemQuery, + variables: { + id: workItemId, + }, + data: workItemQueryResponse.data, + }); + + wrapper = mountExtended(WorkItemLabels, { + provide: { + fullPath: 'test-project-path', + }, + propsData: { + labels, + workItemId, + canUpdate, + }, + attachTo: document.body, + apolloProvider, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('focuses token selector on token selector input event', async () => { + createComponent(); + findTokenSelector().vm.$emit('input', [mockLabels[0]]); + await nextTick(); + + expect(findEmptyState().exists()).toBe(false); + expect(findTokenSelector().element.contains(document.activeElement)).toBe(true); + }); + + it('does not start search by default', () => { + createComponent(); + + expect(findTokenSelector().props('loading')).toBe(false); + expect(findTokenSelector().props('dropdownItems')).toEqual([]); + }); + + it('starts search on hovering for more than 250ms', async () => { + createComponent(); + findTokenSelector().trigger('mouseover'); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(true); + }); + + it('starts search on focusing token selector', async () => { + createComponent(); + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(true); + }); + + it('does not start searching if token-selector was hovered for less than 250ms', async () => { + createComponent(); + findTokenSelector().trigger('mouseover'); + jest.advanceTimersByTime(100); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(false); + }); + + it('does not start searching if cursor was moved out from token selector before 250ms passed', async () => { + createComponent(); + findTokenSelector().trigger('mouseover'); + jest.advanceTimersByTime(100); + + findTokenSelector().trigger('mouseout'); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(false); + }); + + it('shows skeleton loader on dropdown when loading', async () => { + createComponent(); + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('shows list in dropdown when loaded', async () => { + createComponent(); + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + + await waitForPromises(); + + expect(findSkeletonLoader().exists()).toBe(false); + expect(findTokenSelector().props('dropdownItems')).toHaveLength(2); + }); + + it.each([true, false])( + 'passes canUpdate=%s prop to view-only of token-selector', + async (canUpdate) => { + createComponent({ canUpdate }); + + await waitForPromises(); + + expect(findTokenSelector().props('viewOnly')).toBe(!canUpdate); + }, + ); + + it('emits error event if search query fails', async () => { + createComponent({ searchQueryHandler: errorHandler }); + findTokenSelector().vm.$emit('focus'); + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[i18n.fetchError]]); + }); + + it('should search for with correct key after text input', async () => { + const searchKey = 'Hello'; + + createComponent(); + findTokenSelector().vm.$emit('focus'); + findTokenSelector().vm.$emit('text-input', searchKey); + await waitForPromises(); + + expect(successSearchQueryHandler).toHaveBeenCalledWith( + expect.objectContaining({ search: searchKey }), + ); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js new file mode 100644 index 00000000000..93bf7286aa7 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js @@ -0,0 +1,65 @@ +import Vue from 'vue'; +import { GlForm, GlFormCombobox } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import WorkItemLinksForm from '~/work_items/components/work_item_links/work_item_links_form.vue'; +import projectWorkItemsQuery from '~/work_items/graphql/project_work_items.query.graphql'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import { availableWorkItemsResponse, updateWorkItemMutationResponse } from '../../mock_data'; + +Vue.use(VueApollo); + +describe('WorkItemLinksForm', () => { + let wrapper; + + const updateMutationResolver = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); + + const createComponent = async ({ listResponse = availableWorkItemsResponse } = {}) => { + wrapper = shallowMountExtended(WorkItemLinksForm, { + apolloProvider: createMockApollo([ + [projectWorkItemsQuery, jest.fn().mockResolvedValue(listResponse)], + [updateWorkItemMutation, updateMutationResolver], + ]), + propsData: { issuableGid: 'gid://gitlab/WorkItem/1' }, + provide: { + projectPath: 'project/path', + }, + }); + + await waitForPromises(); + }; + + const findForm = () => wrapper.findComponent(GlForm); + const findCombobox = () => wrapper.findComponent(GlFormCombobox); + const findAddChildButton = () => wrapper.findByTestId('add-child-button'); + + beforeEach(async () => { + await createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders form', () => { + expect(findForm().exists()).toBe(true); + }); + + it('passes available work items as prop when typing in combobox', async () => { + findCombobox().vm.$emit('input', 'Task'); + await waitForPromises(); + + expect(findCombobox().exists()).toBe(true); + expect(findCombobox().props('tokenList').length).toBe(2); + }); + + it('selects and add child', async () => { + findCombobox().vm.$emit('input', availableWorkItemsResponse.data.workspace.workItems.edges[0]); + + findAddChildButton().vm.$emit('click'); + await waitForPromises(); + expect(updateMutationResolver).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js new file mode 100644 index 00000000000..f8471b7f167 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js @@ -0,0 +1,141 @@ +import Vue from 'vue'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { cloneDeep } from 'lodash'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import WorkItemLinksMenu from '~/work_items/components/work_item_links/work_item_links_menu.vue'; +import changeWorkItemParentMutation from '~/work_items/graphql/change_work_item_parent_link.mutation.graphql'; +import getWorkItemLinksQuery from '~/work_items/graphql/work_item_links.query.graphql'; +import { WIDGET_TYPE_HIERARCHY } from '~/work_items/constants'; +import { workItemHierarchyResponse, changeWorkItemParentMutationResponse } from '../../mock_data'; + +Vue.use(VueApollo); + +const PARENT_ID = 'gid://gitlab/WorkItem/1'; +const WORK_ITEM_ID = 'gid://gitlab/WorkItem/3'; + +describe('WorkItemLinksMenu', () => { + let wrapper; + let mockApollo; + + const $toast = { + show: jest.fn(), + }; + + const createComponent = async ({ + data = {}, + mutationHandler = jest.fn().mockResolvedValue(changeWorkItemParentMutationResponse), + } = {}) => { + mockApollo = createMockApollo([ + [getWorkItemLinksQuery, jest.fn().mockResolvedValue(workItemHierarchyResponse)], + [changeWorkItemParentMutation, mutationHandler], + ]); + + mockApollo.clients.defaultClient.cache.writeQuery({ + query: getWorkItemLinksQuery, + variables: { + id: PARENT_ID, + }, + data: workItemHierarchyResponse.data, + }); + + wrapper = shallowMountExtended(WorkItemLinksMenu, { + data() { + return { + ...data, + }; + }, + propsData: { + workItemId: WORK_ITEM_ID, + parentWorkItemId: PARENT_ID, + }, + apolloProvider: mockApollo, + mocks: { + $toast, + }, + }); + + await waitForPromises(); + }; + + const findDropdown = () => wrapper.find(GlDropdown); + const findRemoveDropdownItem = () => wrapper.find(GlDropdownItem); + + beforeEach(async () => { + await createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + mockApollo = null; + }); + + it('renders dropdown and dropdown items', () => { + expect(findDropdown().exists()).toBe(true); + expect(findRemoveDropdownItem().exists()).toBe(true); + }); + + it('calls correct mutation with correct variables', async () => { + const mutationHandler = jest.fn().mockResolvedValue(changeWorkItemParentMutationResponse); + + createComponent({ mutationHandler }); + + findRemoveDropdownItem().vm.$emit('click'); + + await waitForPromises(); + + expect(mutationHandler).toHaveBeenCalledWith({ + id: WORK_ITEM_ID, + parentId: null, + }); + }); + + it('shows toast when mutation succeeds', async () => { + const mutationHandler = jest.fn().mockResolvedValue(changeWorkItemParentMutationResponse); + + createComponent({ mutationHandler }); + + findRemoveDropdownItem().vm.$emit('click'); + + await waitForPromises(); + + expect($toast.show).toHaveBeenCalledWith('Child removed', { + action: { onClick: expect.anything(), text: 'Undo' }, + }); + }); + + it('updates the cache when mutation succeeds', async () => { + const mutationHandler = jest.fn().mockResolvedValue(changeWorkItemParentMutationResponse); + + createComponent({ mutationHandler }); + + mockApollo.clients.defaultClient.cache.readQuery = jest.fn( + () => workItemHierarchyResponse.data, + ); + + mockApollo.clients.defaultClient.cache.writeQuery = jest.fn(); + + findRemoveDropdownItem().vm.$emit('click'); + + await waitForPromises(); + + // Remove the work item from parent's children + const resp = cloneDeep(workItemHierarchyResponse); + const index = resp.data.workItem.widgets + .find((widget) => widget.type === WIDGET_TYPE_HIERARCHY) + .children.nodes.findIndex((child) => child.id === WORK_ITEM_ID); + resp.data.workItem.widgets + .find((widget) => widget.type === WIDGET_TYPE_HIERARCHY) + .children.nodes.splice(index, 1); + + expect(mockApollo.clients.defaultClient.cache.writeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.anything(), + variables: { id: PARENT_ID }, + data: resp.data, + }), + ); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js index 774e9198992..2ec9b1ec0ac 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js @@ -51,6 +51,20 @@ describe('WorkItemLinks', () => { expect(findLinksBody().exists()).toBe(false); }); + describe('add link form', () => { + it('displays form on click add button and hides form on cancel', async () => { + findToggleAddFormButton().vm.$emit('click'); + await nextTick(); + + expect(findAddLinksForm().exists()).toBe(true); + + findAddLinksForm().vm.$emit('cancel'); + await nextTick(); + + expect(findAddLinksForm().exists()).toBe(false); + }); + }); + describe('when no child links', () => { beforeEach(async () => { await createComponent({ response: workItemHierarchyEmptyResponse }); @@ -59,22 +73,6 @@ describe('WorkItemLinks', () => { it('displays empty state if there are no children', () => { expect(findEmptyState().exists()).toBe(true); }); - - describe('add link form', () => { - it('displays form on click add button and hides form on cancel', async () => { - expect(findEmptyState().exists()).toBe(true); - - findToggleAddFormButton().vm.$emit('click'); - await nextTick(); - - expect(findAddLinksForm().exists()).toBe(true); - - findAddLinksForm().vm.$emit('cancel'); - await nextTick(); - - expect(findAddLinksForm().exists()).toBe(false); - }); - }); }); it('renders all hierarchy widget children', () => { diff --git a/spec/frontend/work_items/components/work_item_weight_spec.js b/spec/frontend/work_items/components/work_item_weight_spec.js index 80a1d032ad7..c3bbea26cda 100644 --- a/spec/frontend/work_items/components/work_item_weight_spec.js +++ b/spec/frontend/work_items/components/work_item_weight_spec.js @@ -1,21 +1,51 @@ -import { shallowMount } from '@vue/test-utils'; +import { GlForm, GlFormInput } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { mockTracking } from 'helpers/tracking_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { __ } from '~/locale'; import WorkItemWeight from '~/work_items/components/work_item_weight.vue'; +import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; +import localUpdateWorkItemMutation from '~/work_items/graphql/local_update_work_item.mutation.graphql'; -describe('WorkItemAssignees component', () => { +describe('WorkItemWeight component', () => { let wrapper; - const createComponent = ({ weight, hasIssueWeightsFeature = true } = {}) => { - wrapper = shallowMount(WorkItemWeight, { + const mutateSpy = jest.fn(); + const workItemId = 'gid://gitlab/WorkItem/1'; + const workItemType = 'Task'; + + const findForm = () => wrapper.findComponent(GlForm); + const findInput = () => wrapper.findComponent(GlFormInput); + + const createComponent = ({ + canUpdate = false, + hasIssueWeightsFeature = true, + isEditing = false, + weight, + } = {}) => { + wrapper = mountExtended(WorkItemWeight, { propsData: { + canUpdate, weight, + workItemId, + workItemType, }, provide: { hasIssueWeightsFeature, }, + mocks: { + $apollo: { + mutate: mutateSpy, + }, + }, }); + + if (isEditing) { + findInput().vm.$emit('focus'); + } }; - describe('weight licensed feature', () => { + describe('`issue_weights` licensed feature', () => { describe.each` description | hasIssueWeightsFeature | exists ${'when available'} | ${true} | ${true} @@ -24,23 +54,111 @@ describe('WorkItemAssignees component', () => { it(hasIssueWeightsFeature ? 'renders component' : 'does not render component', () => { createComponent({ hasIssueWeightsFeature }); - expect(wrapper.find('div').exists()).toBe(exists); + expect(findForm().exists()).toBe(exists); }); }); }); - describe('weight text', () => { - describe.each` - description | weight | text - ${'renders 1'} | ${1} | ${'1'} - ${'renders 0'} | ${0} | ${'0'} - ${'renders None'} | ${null} | ${'None'} - ${'renders None'} | ${undefined} | ${'None'} - `('when weight is $weight', ({ description, weight, text }) => { - it(description, () => { - createComponent({ weight }); - - expect(wrapper.text()).toContain(text); + describe('weight input', () => { + it('has "Weight" label', () => { + createComponent(); + + expect(wrapper.findByLabelText(__('Weight')).exists()).toBe(true); + }); + + describe('placeholder attribute', () => { + describe.each` + description | isEditing | canUpdate | value + ${'when not editing and cannot update'} | ${false} | ${false} | ${__('None')} + ${'when editing and cannot update'} | ${true} | ${false} | ${__('None')} + ${'when not editing and can update'} | ${false} | ${true} | ${__('None')} + ${'when editing and can update'} | ${true} | ${true} | ${__('Enter a number')} + `('$description', ({ isEditing, canUpdate, value }) => { + it(`has a value of "${value}"`, async () => { + createComponent({ canUpdate, isEditing }); + await nextTick(); + + expect(findInput().attributes('placeholder')).toBe(value); + }); + }); + }); + + describe('readonly attribute', () => { + describe.each` + description | canUpdate | value + ${'when cannot update'} | ${false} | ${'readonly'} + ${'when can update'} | ${true} | ${undefined} + `('$description', ({ canUpdate, value }) => { + it(`renders readonly=${value}`, () => { + createComponent({ canUpdate }); + + expect(findInput().attributes('readonly')).toBe(value); + }); + }); + }); + + describe('type attribute', () => { + describe.each` + description | isEditing | canUpdate | type + ${'when not editing and cannot update'} | ${false} | ${false} | ${'text'} + ${'when editing and cannot update'} | ${true} | ${false} | ${'text'} + ${'when not editing and can update'} | ${false} | ${true} | ${'text'} + ${'when editing and can update'} | ${true} | ${true} | ${'number'} + `('$description', ({ isEditing, canUpdate, type }) => { + it(`has a value of "${type}"`, async () => { + createComponent({ canUpdate, isEditing }); + await nextTick(); + + expect(findInput().attributes('type')).toBe(type); + }); + }); + }); + + describe('value attribute', () => { + describe.each` + weight | value + ${1} | ${'1'} + ${0} | ${'0'} + ${null} | ${''} + ${undefined} | ${''} + `('when `weight` prop is "$weight"', ({ weight, value }) => { + it(`value is "${value}"`, () => { + createComponent({ weight }); + + expect(findInput().element.value).toBe(value); + }); + }); + }); + + describe('when blurred', () => { + it('calls a mutation to update the weight', () => { + const weight = 0; + createComponent({ isEditing: true, weight }); + + findInput().trigger('blur'); + + expect(mutateSpy).toHaveBeenCalledWith({ + mutation: localUpdateWorkItemMutation, + variables: { + input: { + id: workItemId, + weight, + }, + }, + }); + }); + + it('tracks updating the weight', () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + createComponent(); + + findInput().trigger('blur'); + + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_weight', { + category: TRACKING_CATEGORY_SHOW, + label: 'item_weight', + property: 'type_Task', + }); }); }); }); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index bf3f4e1364d..0359caf7116 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -1,3 +1,22 @@ +export const mockAssignees = [ + { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + avatarUrl: '', + webUrl: '', + name: 'John Doe', + username: 'doe_I', + }, + { + __typename: 'UserCore', + id: 'gid://gitlab/User/2', + avatarUrl: '', + webUrl: '', + name: 'Marcus Rutherford', + username: 'ruthfull', + }, +]; + export const workItemQueryResponse = { data: { workItem: { @@ -23,6 +42,32 @@ export const workItemQueryResponse = { descriptionHtml: '<p data-sourcepos="1:1-1:19" dir="auto">some <strong>great</strong> text</p>', }, + { + __typename: 'WorkItemWidgetAssignees', + type: 'ASSIGNEES', + allowsMultipleAssignees: true, + assignees: { + nodes: mockAssignees, + }, + }, + { + __typename: 'WorkItemWidgetHierarchy', + type: 'HIERARCHY', + parent: { + id: 'gid://gitlab/Issue/1', + iid: '5', + title: 'Parent title', + }, + children: { + edges: [ + { + node: { + id: 'gid://gitlab/WorkItem/444', + }, + }, + ], + }, + }, ], }, }, @@ -47,13 +92,28 @@ export const updateWorkItemMutationResponse = { deleteWorkItem: false, updateWorkItem: false, }, - widgets: [], + widgets: [ + { + children: { + edges: [ + { + node: 'gid://gitlab/WorkItem/444', + }, + ], + }, + }, + ], }, }, }, }; -export const workItemResponseFactory = ({ canUpdate } = {}) => ({ +export const workItemResponseFactory = ({ + canUpdate = false, + allowsMultipleAssignees = true, + assigneesWidgetPresent = true, + parent = null, +} = {}) => ({ data: { workItem: { __typename: 'WorkItem', @@ -78,6 +138,30 @@ export const workItemResponseFactory = ({ canUpdate } = {}) => ({ descriptionHtml: '<p data-sourcepos="1:1-1:19" dir="auto">some <strong>great</strong> text</p>', }, + assigneesWidgetPresent + ? { + __typename: 'WorkItemWidgetAssignees', + type: 'ASSIGNEES', + allowsMultipleAssignees, + assignees: { + nodes: mockAssignees, + }, + } + : { type: 'MOCK TYPE' }, + { + __typename: 'WorkItemWidgetHierarchy', + type: 'HIERARCHY', + children: { + edges: [ + { + node: { + id: 'gid://gitlab/WorkItem/444', + }, + }, + ], + }, + parent, + }, ], }, }, @@ -140,13 +224,45 @@ export const createWorkItemFromTaskMutationResponse = { __typename: 'WorkItemCreateFromTaskPayload', errors: [], workItem: { - descriptionHtml: '<p>New description</p>', - id: 'gid://gitlab/WorkItem/13', __typename: 'WorkItem', + description: 'New description', + id: 'gid://gitlab/WorkItem/1', + title: 'Updated title', + state: 'OPEN', + workItemType: { + __typename: 'WorkItemType', + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + }, userPermissions: { deleteWorkItem: false, updateWorkItem: false, }, + widgets: [ + { + __typename: 'WorkItemWidgetDescription', + type: 'DESCRIPTION', + description: 'New description', + descriptionHtml: '<p>New description</p>', + }, + ], + }, + newWorkItem: { + __typename: 'WorkItem', + id: 'gid://gitlab/WorkItem/1000000', + title: 'Updated title', + state: 'OPEN', + description: '', + workItemType: { + __typename: 'WorkItemType', + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + }, + userPermissions: { + deleteWorkItem: false, + updateWorkItem: false, + }, + widgets: [], }, }, }, @@ -275,3 +391,171 @@ export const workItemHierarchyResponse = { }, }, }; + +export const changeWorkItemParentMutationResponse = { + data: { + workItemUpdate: { + workItem: { + id: 'gid://gitlab/WorkItem/2', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/5', + __typename: 'WorkItemType', + }, + title: 'Foo', + state: 'OPEN', + __typename: 'WorkItem', + }, + errors: [], + __typename: 'WorkItemUpdatePayload', + }, + }, +}; + +export const availableWorkItemsResponse = { + data: { + workspace: { + __typename: 'Project', + id: 'gid://gitlab/Project/2', + workItems: { + edges: [ + { + node: { + id: 'gid://gitlab/WorkItem/458', + title: 'Task 1', + state: 'OPEN', + }, + }, + { + node: { + id: 'gid://gitlab/WorkItem/459', + title: 'Task 2', + state: 'OPEN', + }, + }, + ], + }, + }, + }, +}; + +export const projectMembersResponseWithCurrentUser = { + data: { + workspace: { + id: '1', + __typename: 'Project', + users: { + nodes: [ + { + id: 'user-2', + user: { + __typename: 'UserCore', + id: 'gid://gitlab/User/5', + avatarUrl: '/avatar2', + name: 'rookie', + username: 'rookie', + webUrl: 'rookie', + status: null, + }, + }, + { + id: 'user-1', + user: { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + name: 'Administrator', + username: 'root', + webUrl: '/root', + status: null, + }, + }, + ], + }, + }, + }, +}; + +export const projectMembersResponseWithoutCurrentUser = { + data: { + workspace: { + id: '1', + __typename: 'Project', + users: { + nodes: [ + { + id: 'user-2', + user: { + __typename: 'UserCore', + id: 'gid://gitlab/User/5', + avatarUrl: '/avatar2', + name: 'rookie', + username: 'rookie', + webUrl: 'rookie', + status: null, + }, + }, + ], + }, + }, + }, +}; + +export const currentUserResponse = { + data: { + currentUser: { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + name: 'Administrator', + username: 'root', + webUrl: '/root', + }, + }, +}; + +export const currentUserNullResponse = { + data: { + currentUser: null, + }, +}; + +export const mockLabels = [ + { + __typename: 'Label', + id: 'gid://gitlab/Label/1', + title: 'Label 1', + description: '', + color: '#f00', + textColor: '#00f', + }, + { + __typename: 'Label', + id: 'gid://gitlab/Label/2', + title: 'Label 2', + description: '', + color: '#b00', + textColor: '#00b', + }, +]; + +export const projectLabelsResponse = { + data: { + workspace: { + id: '1', + __typename: 'Project', + labels: { + nodes: mockLabels, + }, + }, + }, +}; + +export const mockParent = { + parent: { + id: 'gid://gitlab/Issue/1', + iid: '5', + title: 'Parent title', + }, +}; diff --git a/spec/frontend/work_items/pages/create_work_item_spec.js b/spec/frontend/work_items/pages/create_work_item_spec.js index e89477ed599..fed8be3783a 100644 --- a/spec/frontend/work_items/pages/create_work_item_spec.js +++ b/spec/frontend/work_items/pages/create_work_item_spec.js @@ -9,11 +9,7 @@ import ItemTitle from '~/work_items/components/item_title.vue'; import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql'; import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql'; -import { - projectWorkItemTypesQueryResponse, - createWorkItemMutationResponse, - createWorkItemFromTaskMutationResponse, -} from '../mock_data'; +import { projectWorkItemTypesQueryResponse, createWorkItemMutationResponse } from '../mock_data'; jest.mock('~/lib/utils/uuids', () => ({ uuids: () => ['testuuid'] })); @@ -25,9 +21,6 @@ describe('Create work item component', () => { const querySuccessHandler = jest.fn().mockResolvedValue(projectWorkItemTypesQueryResponse); const createWorkItemSuccessHandler = jest.fn().mockResolvedValue(createWorkItemMutationResponse); - const createWorkItemFromTaskSuccessHandler = jest - .fn() - .mockResolvedValue(createWorkItemFromTaskMutationResponse); const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); const findAlert = () => wrapper.findComponent(GlAlert); @@ -122,49 +115,6 @@ describe('Create work item component', () => { }); }); - describe('when displayed in a modal', () => { - beforeEach(() => { - createComponent({ - props: { - isModal: true, - }, - mutationHandler: createWorkItemFromTaskSuccessHandler, - }); - }); - - it('emits `closeModal` event on Cancel button click', () => { - findCancelButton().vm.$emit('click'); - - expect(wrapper.emitted('closeModal')).toEqual([[]]); - }); - - it('emits `onCreate` on successful mutation', async () => { - findTitleInput().vm.$emit('title-input', 'Test title'); - - wrapper.find('form').trigger('submit'); - await waitForPromises(); - - expect(wrapper.emitted('onCreate')).toEqual([['<p>New description</p>']]); - }); - - it('does not right margin for create button', () => { - expect(findCreateButton().classes()).not.toContain('gl-mr-3'); - }); - - it('adds right margin for cancel button', () => { - expect(findCancelButton().classes()).toContain('gl-mr-3'); - }); - - it('adds padding for content', () => { - expect(findContent().classes('gl-px-5')).toBe(true); - }); - - it('defaults type to `Task`', async () => { - await waitForPromises(); - expect(findSelect().attributes('value')).toBe('gid://gitlab/WorkItems::Type/3'); - }); - }); - it('displays a loading icon inside dropdown when work items query is loading', () => { createComponent(); diff --git a/spec/frontend/work_items/pages/work_item_detail_spec.js b/spec/frontend/work_items/pages/work_item_detail_spec.js index b9724034cb4..43869468ad0 100644 --- a/spec/frontend/work_items/pages/work_item_detail_spec.js +++ b/spec/frontend/work_items/pages/work_item_detail_spec.js @@ -1,26 +1,36 @@ -import { GlAlert, GlSkeletonLoader } from '@gitlab/ui'; +import { GlAlert, GlSkeletonLoader, GlButton } 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 waitForPromises from 'helpers/wait_for_promises'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; import WorkItemDescription from '~/work_items/components/work_item_description.vue'; import WorkItemState from '~/work_items/components/work_item_state.vue'; import WorkItemTitle from '~/work_items/components/work_item_title.vue'; import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; +import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; import WorkItemWeight from '~/work_items/components/work_item_weight.vue'; +import WorkItemInformation from '~/work_items/components/work_item_information.vue'; import { i18n } from '~/work_items/constants'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql'; import { temporaryConfig } from '~/work_items/graphql/provider'; -import { workItemTitleSubscriptionResponse, workItemQueryResponse } from '../mock_data'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import { + workItemTitleSubscriptionResponse, + workItemResponseFactory, + mockParent, +} from '../mock_data'; describe('WorkItemDetail component', () => { let wrapper; + useLocalStorageSpy(); Vue.use(VueApollo); + const workItemQueryResponse = workItemResponseFactory(); const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse); const initialSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse); @@ -30,9 +40,17 @@ describe('WorkItemDetail component', () => { const findWorkItemState = () => wrapper.findComponent(WorkItemState); const findWorkItemDescription = () => wrapper.findComponent(WorkItemDescription); const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssignees); + const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabels); const findWorkItemWeight = () => wrapper.findComponent(WorkItemWeight); + const findParent = () => wrapper.find('[data-testid="work-item-parent"]'); + const findParentButton = () => findParent().findComponent(GlButton); + const findCloseButton = () => wrapper.find('[data-testid="work-item-close"]'); + const findWorkItemType = () => wrapper.find('[data-testid="work-item-type"]'); + const findWorkItemInformationAlert = () => wrapper.findComponent(WorkItemInformation); + const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); const createComponent = ({ + isModal = false, workItemId = workItemQueryResponse.data.workItem.id, handler = successHandler, subscriptionHandler = initialSubscriptionHandler, @@ -50,7 +68,7 @@ describe('WorkItemDetail component', () => { typePolicies: includeWidgets ? temporaryConfig.cacheConfig.typePolicies : {}, }, ), - propsData: { workItemId }, + propsData: { isModal, workItemId }, provide: { glFeatures: { workItemsMvc2: workItemsMvc2Enabled, @@ -98,6 +116,36 @@ describe('WorkItemDetail component', () => { }); }); + describe('close button', () => { + describe('when isModal prop is false', () => { + it('does not render', async () => { + createComponent({ isModal: false }); + await waitForPromises(); + + expect(findCloseButton().exists()).toBe(false); + }); + }); + + describe('when isModal prop is true', () => { + it('renders', async () => { + createComponent({ isModal: true }); + await waitForPromises(); + + expect(findCloseButton().props('icon')).toBe('close'); + expect(findCloseButton().attributes('aria-label')).toBe('Close'); + }); + + it('emits `close` event when clicked', async () => { + createComponent({ isModal: true }); + await waitForPromises(); + + findCloseButton().vm.$emit('click'); + + expect(wrapper.emitted('close')).toEqual([[]]); + }); + }); + }); + describe('description', () => { it('does not show description widget if loading description fails', () => { createComponent(); @@ -107,13 +155,56 @@ describe('WorkItemDetail component', () => { it('shows description widget if description loads', async () => { createComponent(); - await waitForPromises(); expect(findWorkItemDescription().exists()).toBe(true); }); }); + describe('secondary breadcrumbs', () => { + it('does not show secondary breadcrumbs by default', () => { + createComponent(); + + expect(findParent().exists()).toBe(false); + }); + + it('does not show secondary breadcrumbs if there is not a parent', async () => { + createComponent(); + + await waitForPromises(); + + expect(findParent().exists()).toBe(false); + }); + + it('shows work item type if there is not a parent', async () => { + createComponent(); + + await waitForPromises(); + expect(findWorkItemType().exists()).toBe(true); + }); + + describe('with parent', () => { + beforeEach(() => { + const parentResponse = workItemResponseFactory(mockParent); + createComponent({ handler: jest.fn().mockResolvedValue(parentResponse) }); + + return waitForPromises(); + }); + + it('shows secondary breadcrumbs if there is a parent', () => { + expect(findParent().exists()).toBe(true); + }); + + it('does not show work item type', async () => { + expect(findWorkItemType().exists()).toBe(false); + }); + + it('sets the parent breadcrumb URL', () => { + expect(findParentButton().attributes().href).toBe('../../issues/5'); + }); + }); + }); + it('shows an error message when the work item query was unsuccessful', async () => { const errorHandler = jest.fn().mockRejectedValue('Oops'); createComponent({ handler: errorHandler }); @@ -145,7 +236,6 @@ describe('WorkItemDetail component', () => { it('renders assignees component when assignees widget is returned from the API', async () => { createComponent({ workItemsMvc2Enabled: true, - includeWidgets: true, }); await waitForPromises(); @@ -155,7 +245,9 @@ describe('WorkItemDetail component', () => { it('does not render assignees component when assignees widget is not returned from the API', async () => { createComponent({ workItemsMvc2Enabled: true, - includeWidgets: false, + handler: jest + .fn() + .mockResolvedValue(workItemResponseFactory({ assigneesWidgetPresent: false })), }); await waitForPromises(); @@ -170,6 +262,19 @@ describe('WorkItemDetail component', () => { expect(findWorkItemAssignees().exists()).toBe(false); }); + describe('labels widget', () => { + it.each` + description | includeWidgets | exists + ${'renders when widget is returned from API'} | ${true} | ${true} + ${'does not render when widget is not returned from API'} | ${false} | ${false} + `('$description', async ({ includeWidgets, exists }) => { + createComponent({ includeWidgets, workItemsMvc2Enabled: true }); + await waitForPromises(); + + expect(findWorkItemLabels().exists()).toBe(exists); + }); + }); + describe('weight widget', () => { describe('when work_items_mvc_2 feature flag is enabled', () => { describe.each` @@ -201,4 +306,22 @@ describe('WorkItemDetail component', () => { }); }); }); + + describe('work item information', () => { + beforeEach(() => { + createComponent(); + return waitForPromises(); + }); + + it('is visible when viewed for the first time and sets localStorage value', async () => { + localStorage.clear(); + expect(findWorkItemInformationAlert().exists()).toBe(true); + expect(findLocalStorageSync().props('value')).toBe(true); + }); + + it('is not visible after reading local storage input', async () => { + await findLocalStorageSync().vm.$emit('input', false); + expect(findWorkItemInformationAlert().exists()).toBe(false); + }); + }); }); 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 3c5da94114e..d9372f2bcf0 100644 --- a/spec/frontend/work_items/pages/work_item_root_spec.js +++ b/spec/frontend/work_items/pages/work_item_root_spec.js @@ -52,6 +52,7 @@ describe('Work items root component', () => { createComponent(); expect(findWorkItemDetail().props()).toEqual({ + isModal: false, workItemId: 'gid://gitlab/WorkItem/1', workItemParentId: null, }); |