diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-04-20 10:00:54 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-04-20 10:00:54 +0000 |
commit | 3cccd102ba543e02725d247893729e5c73b38295 (patch) | |
tree | f36a04ec38517f5deaaacb5acc7d949688d1e187 /spec/frontend | |
parent | 205943281328046ef7b4528031b90fbda70c75ac (diff) | |
download | gitlab-ce-3cccd102ba543e02725d247893729e5c73b38295.tar.gz |
Add latest changes from gitlab-org/gitlab@14-10-stable-eev14.10.0-rc42
Diffstat (limited to 'spec/frontend')
441 files changed, 14421 insertions, 9874 deletions
diff --git a/spec/frontend/__helpers__/matchers/index.js b/spec/frontend/__helpers__/matchers/index.js index 76571bafb06..9b83ced10e1 100644 --- a/spec/frontend/__helpers__/matchers/index.js +++ b/spec/frontend/__helpers__/matchers/index.js @@ -1,3 +1,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'; diff --git a/spec/frontend/__helpers__/matchers/to_validate_json_schema.js b/spec/frontend/__helpers__/matchers/to_validate_json_schema.js new file mode 100644 index 00000000000..ff391f08c55 --- /dev/null +++ b/spec/frontend/__helpers__/matchers/to_validate_json_schema.js @@ -0,0 +1,34 @@ +// NOTE: Make sure to initialize ajv when using this helper + +const getAjvErrorMessage = ({ errors }) => { + return (errors || []).map((error) => { + return `Error with item ${error.instancePath}: ${error.message}`; + }); +}; + +export function toValidateJsonSchema(testData, validator) { + if (!(validator instanceof Function && validator.schema)) { + return { + validator, + message: () => + 'Validator must be a validating function with property "schema", created with `ajv.compile`. See https://ajv.js.org/api.html#ajv-compile-schema-object-data-any-boolean-promise-any.', + pass: false, + }; + } + + const isValid = validator(testData); + + return { + actual: testData, + message: () => { + if (isValid) { + // We can match, but still fail because we're in a `expect...not.` context + return 'Expected the given data not to pass the schema validation, but found that it was considered valid.'; + } + + const errorMessages = getAjvErrorMessage(validator).join('\n'); + return `Expected the given data to pass the schema validation, but found that it was considered invalid. Errors:\n${errorMessages}`; + }, + pass: isValid, + }; +} diff --git a/spec/frontend/__helpers__/matchers/to_validate_json_schema_spec.js b/spec/frontend/__helpers__/matchers/to_validate_json_schema_spec.js new file mode 100644 index 00000000000..fd42c710c65 --- /dev/null +++ b/spec/frontend/__helpers__/matchers/to_validate_json_schema_spec.js @@ -0,0 +1,65 @@ +import Ajv from 'ajv'; +import AjvFormats from 'ajv-formats'; + +const JSON_SCHEMA = { + type: 'object', + properties: { + fruit: { + type: 'string', + minLength: 3, + }, + }, +}; + +const ajv = new Ajv({ + strictTypes: false, + strictTuples: false, + allowMatchingProperties: true, +}); + +AjvFormats(ajv); +const schema = ajv.compile(JSON_SCHEMA); + +describe('custom matcher toValidateJsonSchema', () => { + it('throws error if validator is not compiled correctly', () => { + expect(() => { + expect({}).toValidateJsonSchema({}); + }).toThrow( + 'Validator must be a validating function with property "schema", created with `ajv.compile`. See https://ajv.js.org/api.html#ajv-compile-schema-object-data-any-boolean-promise-any.', + ); + }); + + describe('positive assertions', () => { + it.each` + description | input + ${'valid input'} | ${{ fruit: 'apple' }} + `('schema validation passes for $description', ({ input }) => { + expect(input).toValidateJsonSchema(schema); + }); + + it('throws if not matching', () => { + expect(() => expect(null).toValidateJsonSchema(schema)).toThrowError( + `Expected the given data to pass the schema validation, but found that it was considered invalid. Errors: +Error with item : must be object`, + ); + }); + }); + + describe('negative assertions', () => { + it.each` + description | input + ${'no input'} | ${null} + ${'input with invalid type'} | ${'banana'} + ${'input with invalid length'} | ${{ fruit: 'aa' }} + ${'input with invalid type'} | ${{ fruit: 12345 }} + `('schema validation fails for $description', ({ input }) => { + expect(input).not.toValidateJsonSchema(schema); + }); + + it('throws if matching', () => { + expect(() => expect({ fruit: 'apple' }).not.toValidateJsonSchema(schema)).toThrowError( + 'Expected the given data not to pass the schema validation, but found that it was considered valid.', + ); + }); + }); +}); diff --git a/spec/frontend/__helpers__/mock_apollo_helper.js b/spec/frontend/__helpers__/mock_apollo_helper.js index c07a6d8ef85..bae9f33be87 100644 --- a/spec/frontend/__helpers__/mock_apollo_helper.js +++ b/spec/frontend/__helpers__/mock_apollo_helper.js @@ -1,7 +1,7 @@ import { InMemoryCache } from '@apollo/client/core'; import { createMockClient as createMockApolloClient } from 'mock-apollo-client'; import VueApollo from 'vue-apollo'; -import possibleTypes from '~/graphql_shared/possibleTypes.json'; +import possibleTypes from '~/graphql_shared/possible_types.json'; import { typePolicies } from '~/lib/graphql'; export function createMockClient(handlers = [], resolvers = {}, cacheOptions = {}) { diff --git a/spec/frontend/__helpers__/mock_dom_observer.js b/spec/frontend/__helpers__/mock_dom_observer.js index dd26b594ad9..bc2646be4c2 100644 --- a/spec/frontend/__helpers__/mock_dom_observer.js +++ b/spec/frontend/__helpers__/mock_dom_observer.js @@ -22,14 +22,14 @@ class MockObserver { takeRecords() {} - // eslint-disable-next-line babel/camelcase + // eslint-disable-next-line camelcase $_triggerObserve(node, { entry = {}, options = {} } = {}) { if (this.$_hasObserver(node, options)) { this.$_cb([{ target: node, ...entry }]); } } - // eslint-disable-next-line babel/camelcase + // eslint-disable-next-line camelcase $_hasObserver(node, options = {}) { return this.$_observers.some( ([obvNode, obvOptions]) => node === obvNode && isMatch(options, obvOptions), diff --git a/spec/frontend/__helpers__/vuex_action_helper.js b/spec/frontend/__helpers__/vuex_action_helper.js index 68203b544ef..95a811d0385 100644 --- a/spec/frontend/__helpers__/vuex_action_helper.js +++ b/spec/frontend/__helpers__/vuex_action_helper.js @@ -49,6 +49,7 @@ const noop = () => {}; * expectedActions: [], * }) */ + export default ( actionArg, payloadArg, diff --git a/spec/frontend/__helpers__/yaml_transformer.js b/spec/frontend/__helpers__/yaml_transformer.js new file mode 100644 index 00000000000..a23f9b1f715 --- /dev/null +++ b/spec/frontend/__helpers__/yaml_transformer.js @@ -0,0 +1,11 @@ +/* eslint-disable import/no-commonjs */ +const JsYaml = require('js-yaml'); + +// This will transform YAML files to JSON strings +module.exports = { + process: (sourceContent) => { + const jsonContent = JsYaml.load(sourceContent); + const json = JSON.stringify(jsonContent); + return `module.exports = ${json}`; + }, +}; diff --git a/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap b/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap index dd742419d32..36003154b58 100644 --- a/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap +++ b/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap @@ -8,7 +8,7 @@ exports[`~/access_tokens/components/expires_at_field should render datepicker wi optionaltext="(optional)" > <gl-datepicker-stub - ariallabel="" + arialabel="" autocomplete="" container="" displayfield="true" diff --git a/spec/frontend/add_context_commits_modal/store/actions_spec.js b/spec/frontend/add_context_commits_modal/store/actions_spec.js index fa4d52cbfbb..4b58a69c2b8 100644 --- a/spec/frontend/add_context_commits_modal/store/actions_spec.js +++ b/spec/frontend/add_context_commits_modal/store/actions_spec.js @@ -42,9 +42,9 @@ describe('AddContextCommitsModalStoreActions', () => { }); describe('setBaseConfig', () => { - it('commits SET_BASE_CONFIG', (done) => { + it('commits SET_BASE_CONFIG', () => { const options = { contextCommitsPath, mergeRequestIid, projectId }; - testAction( + return testAction( setBaseConfig, options, { @@ -59,62 +59,54 @@ describe('AddContextCommitsModalStoreActions', () => { }, ], [], - done, ); }); }); describe('setTabIndex', () => { - it('commits SET_TABINDEX', (done) => { - testAction( + it('commits SET_TABINDEX', () => { + return testAction( setTabIndex, { tabIndex: 1 }, { tabIndex: 0 }, [{ type: types.SET_TABINDEX, payload: { tabIndex: 1 } }], [], - done, ); }); }); describe('setCommits', () => { - it('commits SET_COMMITS', (done) => { - testAction( + it('commits SET_COMMITS', () => { + return testAction( setCommits, { commits: [], silentAddition: false }, { isLoadingCommits: false, commits: [] }, [{ type: types.SET_COMMITS, payload: [] }], [], - done, ); }); - it('commits SET_COMMITS_SILENT', (done) => { - testAction( + it('commits SET_COMMITS_SILENT', () => { + return testAction( setCommits, { commits: [], silentAddition: true }, { isLoadingCommits: true, commits: [] }, [{ type: types.SET_COMMITS_SILENT, payload: [] }], [], - done, ); }); }); describe('createContextCommits', () => { - it('calls API to create context commits', (done) => { + it('calls API to create context commits', async () => { mock.onPost(contextCommitEndpoint).reply(200, {}); - testAction(createContextCommits, { commits: [] }, {}, [], [], done); + await testAction(createContextCommits, { commits: [] }, {}, [], []); - createContextCommits( + await createContextCommits( { state: { projectId, mergeRequestIid }, commit: () => null }, { commits: [] }, - ) - .then(() => { - done(); - }) - .catch(done.fail); + ); }); }); @@ -126,9 +118,9 @@ describe('AddContextCommitsModalStoreActions', () => { ) .reply(200, [dummyCommit]); }); - it('commits FETCH_CONTEXT_COMMITS', (done) => { + it('commits FETCH_CONTEXT_COMMITS', () => { const contextCommit = { ...dummyCommit, isSelected: true }; - testAction( + return testAction( fetchContextCommits, null, { @@ -144,20 +136,18 @@ describe('AddContextCommitsModalStoreActions', () => { { type: 'setCommits', payload: { commits: [contextCommit], silentAddition: true } }, { type: 'setSelectedCommits', payload: [contextCommit] }, ], - done, ); }); }); describe('setContextCommits', () => { - it('commits SET_CONTEXT_COMMITS', (done) => { - testAction( + it('commits SET_CONTEXT_COMMITS', () => { + return testAction( setContextCommits, { data: [] }, { contextCommits: [], isLoadingContextCommits: false }, [{ type: types.SET_CONTEXT_COMMITS, payload: { data: [] } }], [], - done, ); }); }); @@ -168,71 +158,66 @@ describe('AddContextCommitsModalStoreActions', () => { .onDelete('/api/v4/projects/gitlab-org%2Fgitlab/merge_requests/1/context_commits') .reply(204); }); - it('calls API to remove context commits', (done) => { - testAction( + it('calls API to remove context commits', () => { + return testAction( removeContextCommits, { forceReload: false }, { mergeRequestIid, projectId, toRemoveCommits: [] }, [], [], - done, ); }); }); describe('setSelectedCommits', () => { - it('commits SET_SELECTED_COMMITS', (done) => { - testAction( + it('commits SET_SELECTED_COMMITS', () => { + return testAction( setSelectedCommits, [dummyCommit], { selectedCommits: [] }, [{ type: types.SET_SELECTED_COMMITS, payload: [dummyCommit] }], [], - done, ); }); }); describe('setSearchText', () => { - it('commits SET_SEARCH_TEXT', (done) => { + it('commits SET_SEARCH_TEXT', () => { const searchText = 'Dummy Text'; - testAction( + return testAction( setSearchText, searchText, { searchText: '' }, [{ type: types.SET_SEARCH_TEXT, payload: searchText }], [], - done, ); }); }); describe('setToRemoveCommits', () => { - it('commits SET_TO_REMOVE_COMMITS', (done) => { + it('commits SET_TO_REMOVE_COMMITS', () => { const commitId = 'abcde'; - testAction( + return testAction( setToRemoveCommits, [commitId], { toRemoveCommits: [] }, [{ type: types.SET_TO_REMOVE_COMMITS, payload: [commitId] }], [], - done, ); }); }); describe('resetModalState', () => { - it('commits RESET_MODAL_STATE', (done) => { + it('commits RESET_MODAL_STATE', () => { const commitId = 'abcde'; - testAction( + return testAction( resetModalState, null, { toRemoveCommits: [commitId] }, [{ type: types.RESET_MODAL_STATE }], [], - done, ); }); }); diff --git a/spec/frontend/admin/statistics_panel/store/actions_spec.js b/spec/frontend/admin/statistics_panel/store/actions_spec.js index c7481b664b3..e7cdb5feb6a 100644 --- a/spec/frontend/admin/statistics_panel/store/actions_spec.js +++ b/spec/frontend/admin/statistics_panel/store/actions_spec.js @@ -22,8 +22,8 @@ describe('Admin statistics panel actions', () => { mock.onGet(/api\/(.*)\/application\/statistics/).replyOnce(200, mockStatistics); }); - it('dispatches success with received data', (done) => - testAction( + it('dispatches success with received data', () => { + return testAction( actions.fetchStatistics, null, state, @@ -37,8 +37,8 @@ describe('Admin statistics panel actions', () => { ), }, ], - done, - )); + ); + }); }); describe('error', () => { @@ -46,8 +46,8 @@ describe('Admin statistics panel actions', () => { mock.onGet(/api\/(.*)\/application\/statistics/).replyOnce(500); }); - it('dispatches error', (done) => - testAction( + it('dispatches error', () => { + return testAction( actions.fetchStatistics, null, state, @@ -61,26 +61,26 @@ describe('Admin statistics panel actions', () => { payload: new Error('Request failed with status code 500'), }, ], - done, - )); + ); + }); }); }); describe('requestStatistic', () => { - it('should commit the request mutation', (done) => - testAction( + it('should commit the request mutation', () => { + return testAction( actions.requestStatistics, null, state, [{ type: types.REQUEST_STATISTICS }], [], - done, - )); + ); + }); }); describe('receiveStatisticsSuccess', () => { - it('should commit received data', (done) => - testAction( + it('should commit received data', () => { + return testAction( actions.receiveStatisticsSuccess, mockStatistics, state, @@ -91,13 +91,13 @@ describe('Admin statistics panel actions', () => { }, ], [], - done, - )); + ); + }); }); describe('receiveStatisticsError', () => { - it('should commit error', (done) => { - testAction( + it('should commit error', () => { + return testAction( actions.receiveStatisticsError, 500, state, @@ -108,7 +108,6 @@ describe('Admin statistics panel actions', () => { }, ], [], - done, ); }); }); diff --git a/spec/frontend/admin/topics/components/remove_avatar_spec.js b/spec/frontend/admin/topics/components/remove_avatar_spec.js index d4656f0a199..97d257c682c 100644 --- a/spec/frontend/admin/topics/components/remove_avatar_spec.js +++ b/spec/frontend/admin/topics/components/remove_avatar_spec.js @@ -1,10 +1,11 @@ -import { GlButton, GlModal } from '@gitlab/ui'; +import { GlButton, GlModal, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import RemoveAvatar from '~/admin/topics/components/remove_avatar.vue'; const modalID = 'fake-id'; const path = 'topic/path/1'; +const name = 'Topic 1'; jest.mock('lodash/uniqueId', () => () => 'fake-id'); jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); @@ -16,10 +17,14 @@ describe('RemoveAvatar', () => { wrapper = shallowMount(RemoveAvatar, { provide: { path, + name, }, directives: { GlModal: createMockDirective(), }, + stubs: { + GlSprintf, + }, }); }; @@ -55,8 +60,8 @@ describe('RemoveAvatar', () => { const modal = findModal(); expect(modal.exists()).toBe(true); - expect(modal.props('title')).toBe('Confirm remove avatar'); - expect(modal.text()).toBe('Avatar will be removed. Are you sure?'); + expect(modal.props('title')).toBe('Remove topic avatar'); + expect(modal.text()).toBe(`Topic avatar for ${name} will be removed. This cannot be undone.`); }); it('contains the correct modal ID', () => { diff --git a/spec/frontend/admin/users/components/actions/actions_spec.js b/spec/frontend/admin/users/components/actions/actions_spec.js index fa485e73999..b758c15a91a 100644 --- a/spec/frontend/admin/users/components/actions/actions_spec.js +++ b/spec/frontend/admin/users/components/actions/actions_spec.js @@ -1,9 +1,9 @@ import { GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { kebabCase } from 'lodash'; import Actions from '~/admin/users/components/actions'; -import SharedDeleteAction from '~/admin/users/components/actions/shared/shared_delete_action.vue'; +import eventHub, { + EVENT_OPEN_DELETE_USER_MODAL, +} from '~/admin/users/components/modals/delete_user_modal_event_hub'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants'; import { CONFIRMATION_ACTIONS, DELETE_ACTIONS } from '../../constants'; @@ -14,12 +14,11 @@ describe('Action components', () => { const findDropdownItem = () => wrapper.find(GlDropdownItem); - const initComponent = ({ component, props, stubs = {} } = {}) => { + const initComponent = ({ component, props } = {}) => { wrapper = shallowMount(component, { propsData: { ...props, }, - stubs, }); }; @@ -29,7 +28,7 @@ describe('Action components', () => { }); describe('CONFIRMATION_ACTIONS', () => { - it.each(CONFIRMATION_ACTIONS)('renders a dropdown item for "%s"', async (action) => { + it.each(CONFIRMATION_ACTIONS)('renders a dropdown item for "%s"', (action) => { initComponent({ component: Actions[capitalizeFirstCharacter(action)], props: { @@ -38,20 +37,23 @@ describe('Action components', () => { }, }); - await nextTick(); expect(findDropdownItem().exists()).toBe(true); }); }); describe('DELETE_ACTION_COMPONENTS', () => { + beforeEach(() => { + jest.spyOn(eventHub, '$emit').mockImplementation(); + }); + const userDeletionObstacles = [ { name: 'schedule1', type: OBSTACLE_TYPES.oncallSchedules }, { name: 'policy1', type: OBSTACLE_TYPES.escalationPolicies }, ]; - it.each(DELETE_ACTIONS.map((action) => [action, paths[action]]))( - 'renders a dropdown item for "%s"', - async (action, expectedPath) => { + it.each(DELETE_ACTIONS)( + 'renders a dropdown item that opens the delete user modal when clicked for "%s"', + async (action) => { initComponent({ component: Actions[capitalizeFirstCharacter(action)], props: { @@ -59,21 +61,19 @@ describe('Action components', () => { paths, userDeletionObstacles, }, - stubs: { SharedDeleteAction }, }); - await nextTick(); - const sharedAction = wrapper.find(SharedDeleteAction); + await findDropdownItem().vm.$emit('click'); - expect(sharedAction.attributes('data-block-user-url')).toBe(paths.block); - expect(sharedAction.attributes('data-delete-user-url')).toBe(expectedPath); - expect(sharedAction.attributes('data-gl-modal-action')).toBe(kebabCase(action)); - expect(sharedAction.attributes('data-username')).toBe('John Doe'); - expect(sharedAction.attributes('data-user-deletion-obstacles')).toBe( - JSON.stringify(userDeletionObstacles), + expect(eventHub.$emit).toHaveBeenCalledWith( + EVENT_OPEN_DELETE_USER_MODAL, + expect.objectContaining({ + username: 'John Doe', + blockPath: paths.block, + deletePath: paths[action], + userDeletionObstacles, + }), ); - - expect(findDropdownItem().exists()).toBe(true); }, ); }); diff --git a/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap index 7a17ef2cc6c..265569ac0e3 100644 --- a/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap +++ b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap @@ -1,160 +1,28 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`User Operation confirmation modal renders modal with form included 1`] = ` -<div> - <p> - <gl-sprintf-stub - message="content" - /> - </p> - - <user-deletion-obstacles-list-stub - obstacles="schedule1,policy1" - username="username" +exports[`Delete user modal renders modal with form included 1`] = ` +<form + action="" + method="post" +> + <input + name="_method" + type="hidden" + value="delete" /> - <p> - <gl-sprintf-stub - message="To confirm, type %{username}" - /> - </p> - - <form - action="delete-url" - method="post" - > - <input - name="_method" - type="hidden" - value="delete" - /> - - <input - name="authenticity_token" - type="hidden" - value="csrf" - /> - - <gl-form-input-stub - autocomplete="off" - autofocus="" - name="username" - type="text" - value="" - /> - </form> - <gl-button-stub - buttontextclasses="" - category="primary" - icon="" - size="medium" - variant="default" - > - Cancel - </gl-button-stub> - - <gl-button-stub - buttontextclasses="" - category="secondary" - disabled="true" - icon="" - size="medium" - variant="danger" - > - - secondaryAction - - </gl-button-stub> - - <gl-button-stub - buttontextclasses="" - category="primary" - disabled="true" - icon="" - size="medium" - variant="danger" - > - action - </gl-button-stub> -</div> -`; - -exports[`User Operation confirmation modal when user's name has leading and trailing whitespace displays user's name without whitespace 1`] = ` -<div> - <p> - content - </p> - - <user-deletion-obstacles-list-stub - obstacles="schedule1,policy1" - username="John Smith" + <input + name="authenticity_token" + type="hidden" + value="csrf" /> - <p> - To confirm, type - <code - class="gl-white-space-pre-wrap" - > - John Smith - </code> - </p> - - <form - action="delete-url" - method="post" - > - <input - name="_method" - type="hidden" - value="delete" - /> - - <input - name="authenticity_token" - type="hidden" - value="csrf" - /> - - <gl-form-input-stub - autocomplete="off" - autofocus="" - name="username" - type="text" - value="" - /> - </form> - <gl-button-stub - buttontextclasses="" - category="primary" - icon="" - size="medium" - variant="default" - > - Cancel - </gl-button-stub> - - <gl-button-stub - buttontextclasses="" - category="secondary" - disabled="true" - icon="" - size="medium" - variant="danger" - > - - secondaryAction - - </gl-button-stub> - - <gl-button-stub - buttontextclasses="" - category="primary" - disabled="true" - icon="" - size="medium" - variant="danger" - > - action - </gl-button-stub> -</div> + <gl-form-input-stub + autocomplete="off" + autofocus="" + name="username" + type="text" + value="" + /> +</form> `; diff --git a/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js index f875cd24ee1..09a345ac826 100644 --- a/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js +++ b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js @@ -1,6 +1,8 @@ import { GlButton, GlFormInput, GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import eventHub, { + EVENT_OPEN_DELETE_USER_MODAL, +} from '~/admin/users/components/modals/delete_user_modal_event_hub'; import DeleteUserModal from '~/admin/users/components/modals/delete_user_modal.vue'; import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; import ModalStub from './stubs/modal_stub'; @@ -9,7 +11,7 @@ const TEST_DELETE_USER_URL = 'delete-url'; const TEST_BLOCK_USER_URL = 'block-url'; const TEST_CSRF = 'csrf'; -describe('User Operation confirmation modal', () => { +describe('Delete user modal', () => { let wrapper; let formSubmitSpy; @@ -27,28 +29,36 @@ describe('User Operation confirmation modal', () => { const getMethodParam = () => new FormData(findForm().element).get('_method'); const getFormAction = () => findForm().attributes('action'); const findUserDeletionObstaclesList = () => wrapper.findComponent(UserDeletionObstaclesList); + const findMessageUsername = () => wrapper.findByTestId('message-username'); + const findConfirmUsername = () => wrapper.findByTestId('confirm-username'); + const emitOpenModalEvent = (modalData) => { + return eventHub.$emit(EVENT_OPEN_DELETE_USER_MODAL, modalData); + }; const setUsername = (username) => { - findUsernameInput().vm.$emit('input', username); + return findUsernameInput().vm.$emit('input', username); }; const username = 'username'; const badUsername = 'bad_username'; - const userDeletionObstacles = '["schedule1", "policy1"]'; + const userDeletionObstacles = ['schedule1', 'policy1']; + + const mockModalData = { + username, + blockPath: TEST_BLOCK_USER_URL, + deletePath: TEST_DELETE_USER_URL, + userDeletionObstacles, + i18n: { + title: 'Modal for %{username}', + primaryButtonLabel: 'Delete user', + messageBody: 'Delete %{username} or rather %{strongStart}block user%{strongEnd}?', + }, + }; - const createComponent = (props = {}, stubs = {}) => { - wrapper = shallowMount(DeleteUserModal, { + const createComponent = (stubs = {}) => { + wrapper = shallowMountExtended(DeleteUserModal, { propsData: { - username, - title: 'title', - content: 'content', - action: 'action', - secondaryAction: 'secondaryAction', - deleteUserUrl: TEST_DELETE_USER_URL, - blockUserUrl: TEST_BLOCK_USER_URL, csrfToken: TEST_CSRF, - userDeletionObstacles, - ...props, }, stubs: { GlModal: ModalStub, @@ -68,7 +78,7 @@ describe('User Operation confirmation modal', () => { it('renders modal with form included', () => { createComponent(); - expect(wrapper.element).toMatchSnapshot(); + expect(findForm().element).toMatchSnapshot(); }); describe('on created', () => { @@ -83,11 +93,11 @@ describe('User Operation confirmation modal', () => { }); describe('with incorrect username', () => { - beforeEach(async () => { + beforeEach(() => { createComponent(); - setUsername(badUsername); + emitOpenModalEvent(mockModalData); - await nextTick(); + return setUsername(badUsername); }); it('shows incorrect username', () => { @@ -101,11 +111,11 @@ describe('User Operation confirmation modal', () => { }); describe('with correct username', () => { - beforeEach(async () => { + beforeEach(() => { createComponent(); - setUsername(username); + emitOpenModalEvent(mockModalData); - await nextTick(); + return setUsername(username); }); it('shows correct username', () => { @@ -117,11 +127,9 @@ describe('User Operation confirmation modal', () => { expect(findSecondaryButton().attributes('disabled')).toBeFalsy(); }); - describe('when primary action is submitted', () => { - beforeEach(async () => { - findPrimaryButton().vm.$emit('click'); - - await nextTick(); + describe('when primary action is clicked', () => { + beforeEach(() => { + return findPrimaryButton().vm.$emit('click'); }); it('clears the input', () => { @@ -136,11 +144,9 @@ describe('User Operation confirmation modal', () => { }); }); - describe('when secondary action is submitted', () => { - beforeEach(async () => { - findSecondaryButton().vm.$emit('click'); - - await nextTick(); + describe('when secondary action is clicked', () => { + beforeEach(() => { + return findSecondaryButton().vm.$emit('click'); }); it('has correct form attributes and calls submit', () => { @@ -154,22 +160,23 @@ describe('User Operation confirmation modal', () => { describe("when user's name has leading and trailing whitespace", () => { beforeEach(() => { - createComponent( - { - username: ' John Smith ', - }, - { GlSprintf }, - ); + createComponent({ GlSprintf }); + return emitOpenModalEvent({ ...mockModalData, username: ' John Smith ' }); }); it("displays user's name without whitespace", () => { - expect(wrapper.element).toMatchSnapshot(); + expect(findMessageUsername().text()).toBe('John Smith'); + expect(findConfirmUsername().text()).toBe('John Smith'); }); - it("shows enabled buttons when user's name is entered without whitespace", async () => { - setUsername('John Smith'); + it('passes user name without whitespace to the obstacles', () => { + expect(findUserDeletionObstaclesList().props()).toMatchObject({ + userName: 'John Smith', + }); + }); - await nextTick(); + it("shows enabled buttons when user's name is entered without whitespace", async () => { + await setUsername('John Smith'); expect(findPrimaryButton().attributes('disabled')).toBeUndefined(); expect(findSecondaryButton().attributes('disabled')).toBeUndefined(); @@ -177,17 +184,20 @@ describe('User Operation confirmation modal', () => { }); describe('Related user-deletion-obstacles list', () => { - it('does NOT render the list when user has no related obstacles', () => { - createComponent({ userDeletionObstacles: '[]' }); + it('does NOT render the list when user has no related obstacles', async () => { + createComponent(); + await emitOpenModalEvent({ ...mockModalData, userDeletionObstacles: [] }); + expect(findUserDeletionObstaclesList().exists()).toBe(false); }); - it('renders the list when user has related obstalces', () => { + it('renders the list when user has related obstalces', async () => { createComponent(); + await emitOpenModalEvent(mockModalData); const obstacles = findUserDeletionObstaclesList(); expect(obstacles.exists()).toBe(true); - expect(obstacles.props('obstacles')).toEqual(JSON.parse(userDeletionObstacles)); + expect(obstacles.props('obstacles')).toEqual(userDeletionObstacles); }); }); }); diff --git a/spec/frontend/admin/users/components/modals/user_modal_manager_spec.js b/spec/frontend/admin/users/components/modals/user_modal_manager_spec.js deleted file mode 100644 index 4786357faa1..00000000000 --- a/spec/frontend/admin/users/components/modals/user_modal_manager_spec.js +++ /dev/null @@ -1,126 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import UserModalManager from '~/admin/users/components/modals/user_modal_manager.vue'; -import ModalStub from './stubs/modal_stub'; - -describe('Users admin page Modal Manager', () => { - let wrapper; - - const modalConfiguration = { - action1: { - title: 'action1', - content: 'Action Modal 1', - }, - action2: { - title: 'action2', - content: 'Action Modal 2', - }, - }; - - const findModal = () => wrapper.find({ ref: 'modal' }); - - const createComponent = (props = {}) => { - wrapper = mount(UserModalManager, { - propsData: { - selector: '.js-delete-user-modal-button', - modalConfiguration, - csrfToken: 'dummyCSRF', - ...props, - }, - stubs: { - DeleteUserModal: ModalStub, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('render behavior', () => { - it('does not renders modal when initialized', () => { - createComponent(); - expect(findModal().exists()).toBeFalsy(); - }); - - it('throws if action has no proper configuration', () => { - createComponent({ - modalConfiguration: {}, - }); - expect(() => wrapper.vm.show({ glModalAction: 'action1' })).toThrow(); - }); - - it('renders modal with expected props when valid configuration is passed', async () => { - createComponent(); - wrapper.vm.show({ - glModalAction: 'action1', - extraProp: 'extraPropValue', - }); - - await nextTick(); - const modal = findModal(); - expect(modal.exists()).toBeTruthy(); - expect(modal.vm.$attrs.csrfToken).toEqual('dummyCSRF'); - expect(modal.vm.$attrs.extraProp).toEqual('extraPropValue'); - expect(modal.vm.showWasCalled).toBeTruthy(); - }); - }); - - describe('click handling', () => { - let button; - let button2; - - const createButtons = () => { - button = document.createElement('button'); - button2 = document.createElement('button'); - button.setAttribute('class', 'js-delete-user-modal-button'); - button.setAttribute('data-username', 'foo'); - button.setAttribute('data-gl-modal-action', 'action1'); - button.setAttribute('data-block-user-url', '/block'); - button.setAttribute('data-delete-user-url', '/delete'); - document.body.appendChild(button); - document.body.appendChild(button2); - }; - const removeButtons = () => { - button.remove(); - button = null; - button2.remove(); - button2 = null; - }; - - beforeEach(() => { - createButtons(); - createComponent(); - }); - - afterEach(() => { - removeButtons(); - }); - - it('renders the modal when the button is clicked', async () => { - button.click(); - - await nextTick(); - - expect(findModal().exists()).toBe(true); - }); - - it('does not render the modal when a misconfigured button is clicked', async () => { - button.removeAttribute('data-gl-modal-action'); - button.click(); - - await nextTick(); - - expect(findModal().exists()).toBe(false); - }); - - it('does not render the modal when a button without the selector class is clicked', async () => { - button2.click(); - - await nextTick(); - - expect(findModal().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js index 6193233881d..ed185c11732 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js @@ -476,9 +476,6 @@ describe('AlertsSettingsWrapper', () => { destroyHttpIntegration(wrapper); expect(destroyIntegrationHandler).toHaveBeenCalled(); - await waitForPromises(); - - expect(findIntegrations()).toHaveLength(3); }); it('displays flash if mutation had a recoverable error', async () => { diff --git a/spec/frontend/alerts_settings/components/mocks/apollo_mock.js b/spec/frontend/alerts_settings/components/mocks/apollo_mock.js index 694dff56632..170af1b5e0c 100644 --- a/spec/frontend/alerts_settings/components/mocks/apollo_mock.js +++ b/spec/frontend/alerts_settings/components/mocks/apollo_mock.js @@ -102,7 +102,7 @@ export const destroyIntegrationResponse = { httpIntegrationDestroy: { errors: [], integration: { - __typename: 'AlertManagementIntegration', + __typename: 'AlertManagementHttpIntegration', id: '37', type: 'HTTP', active: true, diff --git a/spec/frontend/api/alert_management_alerts_api_spec.js b/spec/frontend/api/alert_management_alerts_api_spec.js new file mode 100644 index 00000000000..aac14e64286 --- /dev/null +++ b/spec/frontend/api/alert_management_alerts_api_spec.js @@ -0,0 +1,140 @@ +import MockAdapter from 'axios-mock-adapter'; +import * as alertManagementAlertsApi from '~/api/alert_management_alerts_api'; +import axios from '~/lib/utils/axios_utils'; + +describe('~/api/alert_management_alerts_api.js', () => { + let mock; + let originalGon; + + const projectId = 1; + const alertIid = 2; + + const imageData = { filePath: 'test', filename: 'hello', id: 5, url: null }; + + beforeEach(() => { + mock = new MockAdapter(axios); + + originalGon = window.gon; + window.gon = { api_version: 'v4' }; + }); + + afterEach(() => { + mock.restore(); + window.gon = originalGon; + }); + + describe('fetchAlertMetricImages', () => { + beforeEach(() => { + jest.spyOn(axios, 'get'); + }); + + it('retrieves metric images from the correct URL and returns them in the response data', () => { + const expectedUrl = `/api/v4/projects/${projectId}/alert_management_alerts/${alertIid}/metric_images`; + const expectedData = [imageData]; + const options = { alertIid, id: projectId }; + + mock.onGet(expectedUrl).reply(200, { data: expectedData }); + + return alertManagementAlertsApi.fetchAlertMetricImages(options).then(({ data }) => { + expect(axios.get).toHaveBeenCalledWith(expectedUrl); + expect(data.data).toEqual(expectedData); + }); + }); + }); + + describe('uploadAlertMetricImage', () => { + beforeEach(() => { + jest.spyOn(axios, 'post'); + }); + + it('uploads a metric image to the correct URL and returns it in the response data', () => { + const expectedUrl = `/api/v4/projects/${projectId}/alert_management_alerts/${alertIid}/metric_images`; + const expectedData = [imageData]; + + const file = new File(['zip contents'], 'hello'); + const url = 'https://www.example.com'; + const urlText = 'Example website'; + + const expectedFormData = new FormData(); + expectedFormData.append('file', file); + expectedFormData.append('url', url); + expectedFormData.append('url_text', urlText); + + mock.onPost(expectedUrl).reply(201, { data: expectedData }); + + return alertManagementAlertsApi + .uploadAlertMetricImage({ + alertIid, + id: projectId, + file, + url, + urlText, + }) + .then(({ data }) => { + expect(data).toEqual({ data: expectedData }); + expect(axios.post).toHaveBeenCalledWith(expectedUrl, expectedFormData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + }); + }); + }); + + describe('updateAlertMetricImage', () => { + beforeEach(() => { + jest.spyOn(axios, 'put'); + }); + + it('updates a metric image to the correct URL and returns it in the response data', () => { + const imageIid = 3; + const expectedUrl = `/api/v4/projects/${projectId}/alert_management_alerts/${alertIid}/metric_images/${imageIid}`; + const expectedData = [imageData]; + + const url = 'https://www.example.com'; + const urlText = 'Example website'; + + const expectedFormData = new FormData(); + expectedFormData.append('url', url); + expectedFormData.append('url_text', urlText); + + mock.onPut(expectedUrl).reply(200, { data: expectedData }); + + return alertManagementAlertsApi + .updateAlertMetricImage({ + alertIid, + id: projectId, + imageId: imageIid, + url, + urlText, + }) + .then(({ data }) => { + expect(data).toEqual({ data: expectedData }); + expect(axios.put).toHaveBeenCalledWith(expectedUrl, expectedFormData); + }); + }); + }); + + describe('deleteAlertMetricImage', () => { + beforeEach(() => { + jest.spyOn(axios, 'delete'); + }); + + it('deletes a metric image to the correct URL and returns it in the response data', () => { + const imageIid = 3; + const expectedUrl = `/api/v4/projects/${projectId}/alert_management_alerts/${alertIid}/metric_images/${imageIid}`; + const expectedData = [imageData]; + + mock.onDelete(expectedUrl).reply(204, { data: expectedData }); + + return alertManagementAlertsApi + .deleteAlertMetricImage({ + alertIid, + id: projectId, + imageId: imageIid, + }) + .then(({ data }) => { + expect(data).toEqual({ data: expectedData }); + expect(axios.delete).toHaveBeenCalledWith(expectedUrl); + }); + }); + }); +}); diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index bc3e12d3fc4..85332bf21d8 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -2,6 +2,9 @@ import MockAdapter from 'axios-mock-adapter'; import Api, { DEFAULT_PER_PAGE } from '~/api'; import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; +import createFlash from '~/flash'; + +jest.mock('~/flash'); describe('Api', () => { const dummyApiVersion = 'v3000'; @@ -155,66 +158,44 @@ describe('Api', () => { }); describe('group', () => { - it('fetches a group', (done) => { + it('fetches a group', () => { const groupId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}`; mock.onGet(expectedUrl).reply(httpStatus.OK, { name: 'test', }); - Api.group(groupId, (response) => { - expect(response.name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.group(groupId, (response) => { + expect(response.name).toBe('test'); + resolve(); + }); }); }); }); describe('groupMembers', () => { - it('fetches group members', (done) => { + it('fetches group members', () => { const groupId = '54321'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/members`; const expectedData = [{ id: 7 }]; mock.onGet(expectedUrl).reply(httpStatus.OK, expectedData); - Api.groupMembers(groupId) - .then(({ data }) => { - expect(data).toEqual(expectedData); - }) - .then(done) - .catch(done.fail); - }); - }); - - describe('addGroupMembersByUserId', () => { - it('adds an existing User as a new Group Member by User ID', () => { - const groupId = 1; - const expectedUserId = 2; - const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/members`; - const params = { - user_id: expectedUserId, - access_level: 10, - expires_at: undefined, - }; - - mock.onPost(expectedUrl).reply(200, { - id: expectedUserId, - state: 'active', - }); - - return Api.addGroupMembersByUserId(groupId, params).then(({ data }) => { - expect(data.id).toBe(expectedUserId); - expect(data.state).toBe('active'); + return Api.groupMembers(groupId).then(({ data }) => { + expect(data).toEqual(expectedData); }); }); }); - describe('inviteGroupMembersByEmail', () => { + describe('inviteGroupMembers', () => { it('invites a new email address to create a new User and become a Group Member', () => { const groupId = 1; const email = 'email@example.com'; + const userId = '1'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/invitations`; const params = { email, + userId, access_level: 10, expires_at: undefined, }; @@ -223,14 +204,14 @@ describe('Api', () => { status: 'success', }); - return Api.inviteGroupMembersByEmail(groupId, params).then(({ data }) => { + return Api.inviteGroupMembers(groupId, params).then(({ data }) => { expect(data.status).toBe('success'); }); }); }); describe('groupMilestones', () => { - it('fetches group milestones', (done) => { + it('fetches group milestones', () => { const groupId = '16'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/milestones`; const expectedData = [ @@ -250,17 +231,14 @@ describe('Api', () => { ]; mock.onGet(expectedUrl).reply(httpStatus.OK, expectedData); - Api.groupMilestones(groupId) - .then(({ data }) => { - expect(data).toEqual(expectedData); - }) - .then(done) - .catch(done.fail); + return Api.groupMilestones(groupId).then(({ data }) => { + expect(data).toEqual(expectedData); + }); }); }); describe('groups', () => { - it('fetches groups', (done) => { + it('fetches groups', () => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups.json`; @@ -270,16 +248,18 @@ describe('Api', () => { }, ]); - Api.groups(query, options, (response) => { - expect(response.length).toBe(1); - expect(response[0].name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.groups(query, options, (response) => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + resolve(); + }); }); }); }); describe('groupLabels', () => { - it('fetches group labels', (done) => { + it('fetches group labels', () => { const options = { params: { search: 'foo' } }; const expectedGroup = 'gitlab-org'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${expectedGroup}/labels`; @@ -290,18 +270,15 @@ describe('Api', () => { }, ]); - Api.groupLabels(expectedGroup, options) - .then((res) => { - expect(res.length).toBe(1); - expect(res[0].name).toBe('Foo Label'); - }) - .then(done) - .catch(done.fail); + return Api.groupLabels(expectedGroup, options).then((res) => { + expect(res.length).toBe(1); + expect(res[0].name).toBe('Foo Label'); + }); }); }); describe('namespaces', () => { - it('fetches namespaces', (done) => { + it('fetches namespaces', () => { const query = 'dummy query'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/namespaces.json`; mock.onGet(expectedUrl).reply(httpStatus.OK, [ @@ -310,16 +287,18 @@ describe('Api', () => { }, ]); - Api.namespaces(query, (response) => { - expect(response.length).toBe(1); - expect(response[0].name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.namespaces(query, (response) => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + resolve(); + }); }); }); }); describe('projects', () => { - it('fetches projects with membership when logged in', (done) => { + it('fetches projects with membership when logged in', () => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`; @@ -330,14 +309,16 @@ describe('Api', () => { }, ]); - Api.projects(query, options, (response) => { - expect(response.length).toBe(1); - expect(response[0].name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.projects(query, options, (response) => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + resolve(); + }); }); }); - it('fetches projects without membership when not logged in', (done) => { + it('fetches projects without membership when not logged in', () => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`; @@ -347,31 +328,30 @@ describe('Api', () => { }, ]); - Api.projects(query, options, (response) => { - expect(response.length).toBe(1); - expect(response[0].name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.projects(query, options, (response) => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + resolve(); + }); }); }); }); describe('updateProject', () => { - it('update a project with the given payload', (done) => { + it('update a project with the given payload', () => { const projectPath = 'foo'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}`; mock.onPut(expectedUrl).reply(httpStatus.OK, { foo: 'bar' }); - Api.updateProject(projectPath, { foo: 'bar' }) - .then(({ data }) => { - expect(data.foo).toBe('bar'); - done(); - }) - .catch(done.fail); + return Api.updateProject(projectPath, { foo: 'bar' }).then(({ data }) => { + expect(data.foo).toBe('bar'); + }); }); }); describe('projectUsers', () => { - it('fetches all users of a particular project', (done) => { + it('fetches all users of a particular project', () => { const query = 'dummy query'; const options = { unused: 'option' }; const projectPath = 'gitlab-org%2Fgitlab-ce'; @@ -382,13 +362,10 @@ describe('Api', () => { }, ]); - Api.projectUsers('gitlab-org/gitlab-ce', query, options) - .then((response) => { - expect(response.length).toBe(1); - expect(response[0].name).toBe('test'); - }) - .then(done) - .catch(done.fail); + return Api.projectUsers('gitlab-org/gitlab-ce', query, options).then((response) => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + }); }); }); @@ -396,38 +373,32 @@ describe('Api', () => { const projectPath = 'abc'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests`; - it('fetches all merge requests for a project', (done) => { + it('fetches all merge requests for a project', () => { const mockData = [{ source_branch: 'foo' }, { source_branch: 'bar' }]; mock.onGet(expectedUrl).reply(httpStatus.OK, mockData); - Api.projectMergeRequests(projectPath) - .then(({ data }) => { - expect(data.length).toEqual(2); - expect(data[0].source_branch).toBe('foo'); - expect(data[1].source_branch).toBe('bar'); - }) - .then(done) - .catch(done.fail); + return Api.projectMergeRequests(projectPath).then(({ data }) => { + expect(data.length).toEqual(2); + expect(data[0].source_branch).toBe('foo'); + expect(data[1].source_branch).toBe('bar'); + }); }); - it('fetches merge requests filtered with passed params', (done) => { + it('fetches merge requests filtered with passed params', () => { const params = { source_branch: 'bar', }; const mockData = [{ source_branch: 'bar' }]; mock.onGet(expectedUrl, { params }).reply(httpStatus.OK, mockData); - Api.projectMergeRequests(projectPath, params) - .then(({ data }) => { - expect(data.length).toEqual(1); - expect(data[0].source_branch).toBe('bar'); - }) - .then(done) - .catch(done.fail); + return Api.projectMergeRequests(projectPath, params).then(({ data }) => { + expect(data.length).toEqual(1); + expect(data[0].source_branch).toBe('bar'); + }); }); }); describe('projectMergeRequest', () => { - it('fetches a merge request', (done) => { + it('fetches a merge request', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}`; @@ -435,17 +406,14 @@ describe('Api', () => { title: 'test', }); - Api.projectMergeRequest(projectPath, mergeRequestId) - .then(({ data }) => { - expect(data.title).toBe('test'); - }) - .then(done) - .catch(done.fail); + return Api.projectMergeRequest(projectPath, mergeRequestId).then(({ data }) => { + expect(data.title).toBe('test'); + }); }); }); describe('projectMergeRequestChanges', () => { - it('fetches the changes of a merge request', (done) => { + it('fetches the changes of a merge request', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/changes`; @@ -453,17 +421,14 @@ describe('Api', () => { title: 'test', }); - Api.projectMergeRequestChanges(projectPath, mergeRequestId) - .then(({ data }) => { - expect(data.title).toBe('test'); - }) - .then(done) - .catch(done.fail); + return Api.projectMergeRequestChanges(projectPath, mergeRequestId).then(({ data }) => { + expect(data.title).toBe('test'); + }); }); }); describe('projectMergeRequestVersions', () => { - it('fetches the versions of a merge request', (done) => { + it('fetches the versions of a merge request', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/versions`; @@ -473,30 +438,24 @@ describe('Api', () => { }, ]); - Api.projectMergeRequestVersions(projectPath, mergeRequestId) - .then(({ data }) => { - expect(data.length).toBe(1); - expect(data[0].id).toBe(123); - }) - .then(done) - .catch(done.fail); + return Api.projectMergeRequestVersions(projectPath, mergeRequestId).then(({ data }) => { + expect(data.length).toBe(1); + expect(data[0].id).toBe(123); + }); }); }); describe('projectRunners', () => { - it('fetches the runners of a project', (done) => { + it('fetches the runners of a project', () => { const projectPath = 7; const params = { scope: 'active' }; const mockData = [{ id: 4 }]; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/runners`; mock.onGet(expectedUrl, { params }).reply(httpStatus.OK, mockData); - Api.projectRunners(projectPath, { params }) - .then(({ data }) => { - expect(data).toEqual(mockData); - }) - .then(done) - .catch(done.fail); + return Api.projectRunners(projectPath, { params }).then(({ data }) => { + expect(data).toEqual(mockData); + }); }); }); @@ -525,7 +484,7 @@ describe('Api', () => { }); describe('projectMilestones', () => { - it('fetches project milestones', (done) => { + it('fetches project milestones', () => { const projectId = 1; const options = { state: 'active' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/milestones`; @@ -537,13 +496,10 @@ describe('Api', () => { }, ]); - Api.projectMilestones(projectId, options) - .then(({ data }) => { - expect(data.length).toBe(1); - expect(data[0].title).toBe('milestone1'); - }) - .then(done) - .catch(done.fail); + return Api.projectMilestones(projectId, options).then(({ data }) => { + expect(data.length).toBe(1); + expect(data[0].title).toBe('milestone1'); + }); }); }); @@ -566,36 +522,15 @@ describe('Api', () => { }); }); - describe('addProjectMembersByUserId', () => { - it('adds an existing User as a new Project Member by User ID', () => { - const projectId = 1; - const expectedUserId = 2; - const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/members`; - const params = { - user_id: expectedUserId, - access_level: 10, - expires_at: undefined, - }; - - mock.onPost(expectedUrl).reply(200, { - id: expectedUserId, - state: 'active', - }); - - return Api.addProjectMembersByUserId(projectId, params).then(({ data }) => { - expect(data.id).toBe(expectedUserId); - expect(data.state).toBe('active'); - }); - }); - }); - - describe('inviteProjectMembersByEmail', () => { + describe('inviteProjectMembers', () => { it('invites a new email address to create a new User and become a Project Member', () => { const projectId = 1; - const expectedEmail = 'email@example.com'; + const email = 'email@example.com'; + const userId = '1'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/invitations`; const params = { - email: expectedEmail, + email, + userId, access_level: 10, expires_at: undefined, }; @@ -604,14 +539,14 @@ describe('Api', () => { status: 'success', }); - return Api.inviteProjectMembersByEmail(projectId, params).then(({ data }) => { + return Api.inviteProjectMembers(projectId, params).then(({ data }) => { expect(data.status).toBe('success'); }); }); }); describe('newLabel', () => { - it('creates a new project label', (done) => { + it('creates a new project label', () => { const namespace = 'some namespace'; const project = 'some project'; const labelData = { some: 'data' }; @@ -630,13 +565,15 @@ describe('Api', () => { ]; }); - Api.newLabel(namespace, project, labelData, (response) => { - expect(response.name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.newLabel(namespace, project, labelData, (response) => { + expect(response.name).toBe('test'); + resolve(); + }); }); }); - it('creates a new group label', (done) => { + it('creates a new group label', () => { const namespace = 'group/subgroup'; const labelData = { name: 'Foo', color: '#000000' }; const expectedUrl = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespace); @@ -651,15 +588,17 @@ describe('Api', () => { ]; }); - Api.newLabel(namespace, undefined, labelData, (response) => { - expect(response.name).toBe('Foo'); - done(); + return new Promise((resolve) => { + Api.newLabel(namespace, undefined, labelData, (response) => { + expect(response.name).toBe('Foo'); + resolve(); + }); }); }); }); describe('groupProjects', () => { - it('fetches group projects', (done) => { + it('fetches group projects', () => { const groupId = '123456'; const query = 'dummy query'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/projects.json`; @@ -669,11 +608,40 @@ describe('Api', () => { }, ]); - Api.groupProjects(groupId, query, {}, (response) => { - expect(response.length).toBe(1); - expect(response[0].name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.groupProjects(groupId, query, {}, (response) => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + resolve(); + }); + }); + }); + + it('uses flesh on error by default', async () => { + const groupId = '123456'; + const query = 'dummy query'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/projects.json`; + const flashCallback = (callCount) => { + expect(createFlash).toHaveBeenCalledTimes(callCount); + createFlash.mockClear(); + }; + + mock.onGet(expectedUrl).reply(500, null); + + const response = await Api.groupProjects(groupId, query, {}, () => {}).then(() => { + flashCallback(1); }); + expect(response).toBeUndefined(); + }); + + it('NOT uses flesh on error with param useCustomErrorHandler', async () => { + const groupId = '123456'; + const query = 'dummy query'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/projects.json`; + + mock.onGet(expectedUrl).reply(500, null); + const apiCall = Api.groupProjects(groupId, query, {}, () => {}, true); + await expect(apiCall).rejects.toThrow(); }); }); @@ -734,12 +702,14 @@ describe('Api', () => { templateKey, )}`; - it('fetches an issue template', (done) => { + it('fetches an issue template', () => { mock.onGet(expectedUrl).reply(httpStatus.OK, 'test'); - Api.issueTemplate(namespace, project, templateKey, templateType, (error, response) => { - expect(response).toBe('test'); - done(); + return new Promise((resolve) => { + Api.issueTemplate(namespace, project, templateKey, templateType, (_, response) => { + expect(response).toBe('test'); + resolve(); + }); }); }); @@ -747,8 +717,11 @@ describe('Api', () => { it('rejects the Promise', () => { mock.onGet(expectedUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); - Api.issueTemplate(namespace, project, templateKey, templateType, () => { - expect(mock.history.get).toHaveLength(1); + return new Promise((resolve) => { + Api.issueTemplate(namespace, project, templateKey, templateType, () => { + expect(mock.history.get).toHaveLength(1); + resolve(); + }); }); }); }); @@ -760,19 +733,21 @@ describe('Api', () => { const templateType = 'template type'; const expectedUrl = `${dummyUrlRoot}/${namespace}/${project}/templates/${templateType}`; - it('fetches all templates by type', (done) => { + it('fetches all templates by type', () => { const expectedData = [ { key: 'Template1', name: 'Template 1', content: 'This is template 1!' }, ]; mock.onGet(expectedUrl).reply(httpStatus.OK, expectedData); - Api.issueTemplates(namespace, project, templateType, (error, response) => { - expect(response.length).toBe(1); - const { key, name, content } = response[0]; - expect(key).toBe('Template1'); - expect(name).toBe('Template 1'); - expect(content).toBe('This is template 1!'); - done(); + return new Promise((resolve) => { + Api.issueTemplates(namespace, project, templateType, (_, response) => { + expect(response.length).toBe(1); + const { key, name, content } = response[0]; + expect(key).toBe('Template1'); + expect(name).toBe('Template 1'); + expect(content).toBe('This is template 1!'); + resolve(); + }); }); }); @@ -788,34 +763,44 @@ describe('Api', () => { }); describe('projectTemplates', () => { - it('fetches a list of templates', (done) => { + it('fetches a list of templates', () => { const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/gitlab-org%2Fgitlab-ce/templates/licenses`; mock.onGet(expectedUrl).reply(httpStatus.OK, 'test'); - Api.projectTemplates('gitlab-org/gitlab-ce', 'licenses', {}, (response) => { - expect(response).toBe('test'); - done(); + return new Promise((resolve) => { + Api.projectTemplates('gitlab-org/gitlab-ce', 'licenses', {}, (response) => { + expect(response).toBe('test'); + resolve(); + }); }); }); }); describe('projectTemplate', () => { - it('fetches a single template', (done) => { + it('fetches a single template', () => { const data = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/gitlab-org%2Fgitlab-ce/templates/licenses/test%20license`; mock.onGet(expectedUrl).reply(httpStatus.OK, 'test'); - Api.projectTemplate('gitlab-org/gitlab-ce', 'licenses', 'test license', data, (response) => { - expect(response).toBe('test'); - done(); + return new Promise((resolve) => { + Api.projectTemplate( + 'gitlab-org/gitlab-ce', + 'licenses', + 'test license', + data, + (response) => { + expect(response).toBe('test'); + resolve(); + }, + ); }); }); }); describe('users', () => { - it('fetches users', (done) => { + it('fetches users', () => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users.json`; @@ -825,68 +810,56 @@ describe('Api', () => { }, ]); - Api.users(query, options) - .then(({ data }) => { - expect(data.length).toBe(1); - expect(data[0].name).toBe('test'); - }) - .then(done) - .catch(done.fail); + return Api.users(query, options).then(({ data }) => { + expect(data.length).toBe(1); + expect(data[0].name).toBe('test'); + }); }); }); describe('user', () => { - it('fetches single user', (done) => { + it('fetches single user', () => { const userId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}`; mock.onGet(expectedUrl).reply(httpStatus.OK, { name: 'testuser', }); - Api.user(userId) - .then(({ data }) => { - expect(data.name).toBe('testuser'); - }) - .then(done) - .catch(done.fail); + return Api.user(userId).then(({ data }) => { + expect(data.name).toBe('testuser'); + }); }); }); describe('user counts', () => { - it('fetches single user counts', (done) => { + it('fetches single user counts', () => { const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/user_counts`; mock.onGet(expectedUrl).reply(httpStatus.OK, { merge_requests: 4, }); - Api.userCounts() - .then(({ data }) => { - expect(data.merge_requests).toBe(4); - }) - .then(done) - .catch(done.fail); + return Api.userCounts().then(({ data }) => { + expect(data.merge_requests).toBe(4); + }); }); }); describe('user status', () => { - it('fetches single user status', (done) => { + it('fetches single user status', () => { const userId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}/status`; mock.onGet(expectedUrl).reply(httpStatus.OK, { message: 'testmessage', }); - Api.userStatus(userId) - .then(({ data }) => { - expect(data.message).toBe('testmessage'); - }) - .then(done) - .catch(done.fail); + return Api.userStatus(userId).then(({ data }) => { + expect(data.message).toBe('testmessage'); + }); }); }); describe('user projects', () => { - it('fetches all projects that belong to a particular user', (done) => { + it('fetches all projects that belong to a particular user', () => { const query = 'dummy query'; const options = { unused: 'option' }; const userId = '123456'; @@ -897,16 +870,18 @@ describe('Api', () => { }, ]); - Api.userProjects(userId, query, options, (response) => { - expect(response.length).toBe(1); - expect(response[0].name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.userProjects(userId, query, options, (response) => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + resolve(); + }); }); }); }); describe('commitPipelines', () => { - it('fetches pipelines for a given commit', (done) => { + it('fetches pipelines for a given commit', () => { const projectId = 'example/foobar'; const commitSha = 'abc123def'; const expectedUrl = `${dummyUrlRoot}/${projectId}/commit/${commitSha}/pipelines`; @@ -916,13 +891,10 @@ describe('Api', () => { }, ]); - Api.commitPipelines(projectId, commitSha) - .then(({ data }) => { - expect(data.length).toBe(1); - expect(data[0].name).toBe('test'); - }) - .then(done) - .catch(done.fail); + return Api.commitPipelines(projectId, commitSha).then(({ data }) => { + expect(data.length).toBe(1); + expect(data[0].name).toBe('test'); + }); }); }); @@ -947,7 +919,7 @@ describe('Api', () => { }); describe('createBranch', () => { - it('creates new branch', (done) => { + it('creates new branch', () => { const ref = 'main'; const branch = 'new-branch-name'; const dummyProjectPath = 'gitlab-org/gitlab-ce'; @@ -961,18 +933,15 @@ describe('Api', () => { name: branch, }); - Api.createBranch(dummyProjectPath, { ref, branch }) - .then(({ data }) => { - expect(data.name).toBe(branch); - expect(axios.post).toHaveBeenCalledWith(expectedUrl, { ref, branch }); - }) - .then(done) - .catch(done.fail); + return Api.createBranch(dummyProjectPath, { ref, branch }).then(({ data }) => { + expect(data.name).toBe(branch); + expect(axios.post).toHaveBeenCalledWith(expectedUrl, { ref, branch }); + }); }); }); describe('projectForks', () => { - it('gets forked projects', (done) => { + it('gets forked projects', () => { const dummyProjectPath = 'gitlab-org/gitlab-ce'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${encodeURIComponent( dummyProjectPath, @@ -982,20 +951,17 @@ describe('Api', () => { mock.onGet(expectedUrl).replyOnce(httpStatus.OK, ['fork']); - Api.projectForks(dummyProjectPath, { visibility: 'private' }) - .then(({ data }) => { - expect(data).toEqual(['fork']); - expect(axios.get).toHaveBeenCalledWith(expectedUrl, { - params: { visibility: 'private' }, - }); - }) - .then(done) - .catch(done.fail); + return Api.projectForks(dummyProjectPath, { visibility: 'private' }).then(({ data }) => { + expect(data).toEqual(['fork']); + expect(axios.get).toHaveBeenCalledWith(expectedUrl, { + params: { visibility: 'private' }, + }); + }); }); }); describe('createContextCommits', () => { - it('creates a new context commit', (done) => { + it('creates a new context commit', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const commitsData = ['abcdefg']; @@ -1014,17 +980,16 @@ describe('Api', () => { }, ]); - Api.createContextCommits(projectPath, mergeRequestId, expectedData) - .then(({ data }) => { + return Api.createContextCommits(projectPath, mergeRequestId, expectedData).then( + ({ data }) => { expect(data[0].title).toBe('Dummy commit'); - }) - .then(done) - .catch(done.fail); + }, + ); }); }); describe('allContextCommits', () => { - it('gets all context commits', (done) => { + it('gets all context commits', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/context_commits`; @@ -1035,17 +1000,14 @@ describe('Api', () => { .onGet(expectedUrl) .replyOnce(200, [{ id: 'abcdef', short_id: 'abcdefghi', title: 'Dummy commit title' }]); - Api.allContextCommits(projectPath, mergeRequestId) - .then(({ data }) => { - expect(data[0].title).toBe('Dummy commit title'); - }) - .then(done) - .catch(done.fail); + return Api.allContextCommits(projectPath, mergeRequestId).then(({ data }) => { + expect(data[0].title).toBe('Dummy commit title'); + }); }); }); describe('removeContextCommits', () => { - it('removes context commits', (done) => { + it('removes context commits', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const commitsData = ['abcdefg']; @@ -1058,12 +1020,9 @@ describe('Api', () => { mock.onDelete(expectedUrl).replyOnce(204); - Api.removeContextCommits(projectPath, mergeRequestId, expectedData) - .then(() => { - expect(axios.delete).toHaveBeenCalledWith(expectedUrl, { data: expectedData }); - }) - .then(done) - .catch(done.fail); + return Api.removeContextCommits(projectPath, mergeRequestId, expectedData).then(() => { + expect(axios.delete).toHaveBeenCalledWith(expectedUrl, { data: expectedData }); + }); }); }); @@ -1306,41 +1265,37 @@ describe('Api', () => { }); describe('updateIssue', () => { - it('update an issue with the given payload', (done) => { + it('update an issue with the given payload', () => { const projectId = 8; const issue = 1; const expectedArray = [1, 2, 3]; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/issues/${issue}`; mock.onPut(expectedUrl).reply(httpStatus.OK, { assigneeIds: expectedArray }); - Api.updateIssue(projectId, issue, { assigneeIds: expectedArray }) - .then(({ data }) => { - expect(data.assigneeIds).toEqual(expectedArray); - done(); - }) - .catch(done.fail); + return Api.updateIssue(projectId, issue, { assigneeIds: expectedArray }).then(({ data }) => { + expect(data.assigneeIds).toEqual(expectedArray); + }); }); }); describe('updateMergeRequest', () => { - it('update an issue with the given payload', (done) => { + it('update an issue with the given payload', () => { const projectId = 8; const mergeRequest = 1; const expectedArray = [1, 2, 3]; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/merge_requests/${mergeRequest}`; mock.onPut(expectedUrl).reply(httpStatus.OK, { assigneeIds: expectedArray }); - Api.updateMergeRequest(projectId, mergeRequest, { assigneeIds: expectedArray }) - .then(({ data }) => { + return Api.updateMergeRequest(projectId, mergeRequest, { assigneeIds: expectedArray }).then( + ({ data }) => { expect(data.assigneeIds).toEqual(expectedArray); - done(); - }) - .catch(done.fail); + }, + ); }); }); describe('tags', () => { - it('fetches all tags of a particular project', (done) => { + it('fetches all tags of a particular project', () => { const query = 'dummy query'; const options = { unused: 'option' }; const projectId = 8; @@ -1351,13 +1306,10 @@ describe('Api', () => { }, ]); - Api.tags(projectId, query, options) - .then(({ data }) => { - expect(data.length).toBe(1); - expect(data[0].name).toBe('test'); - }) - .then(done) - .catch(done.fail); + return Api.tags(projectId, query, options).then(({ data }) => { + expect(data.length).toBe(1); + expect(data[0].name).toBe('test'); + }); }); }); @@ -1641,6 +1593,18 @@ describe('Api', () => { }); }); + describe('dependency proxy cache', () => { + it('schedules the cache list for deletion', async () => { + const groupId = 1; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/dependency_proxy/cache`; + + mock.onDelete(expectedUrl).reply(httpStatus.ACCEPTED); + const { status } = await Api.deleteDependencyProxyCacheList(groupId, {}); + + expect(status).toBe(httpStatus.ACCEPTED); + }); + }); + describe('Feature Flag User List', () => { let expectedUrl; let projectId; @@ -1727,4 +1691,36 @@ describe('Api', () => { }); }); }); + + describe('projectProtectedBranch', () => { + const branchName = 'new-branch-name'; + const dummyProjectId = 5; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${dummyProjectId}/protected_branches/${branchName}`; + + it('returns 404 for non-existing branch', () => { + jest.spyOn(axios, 'get'); + + mock.onGet(expectedUrl).replyOnce(httpStatus.NOT_FOUND, { + message: '404 Not found', + }); + + return Api.projectProtectedBranch(dummyProjectId, branchName).catch((error) => { + expect(error.response.status).toBe(httpStatus.NOT_FOUND); + expect(axios.get).toHaveBeenCalledWith(expectedUrl); + }); + }); + + it('returns 200 with branch information', () => { + const expectedObj = { name: branchName }; + + jest.spyOn(axios, 'get'); + + mock.onGet(expectedUrl).replyOnce(httpStatus.OK, expectedObj); + + return Api.projectProtectedBranch(dummyProjectId, branchName).then((data) => { + expect(data).toEqual(expectedObj); + expect(axios.get).toHaveBeenCalledWith(expectedUrl); + }); + }); + }); }); diff --git a/spec/frontend/authentication/u2f/authenticate_spec.js b/spec/frontend/authentication/u2f/authenticate_spec.js index 153d4be56af..31782899ce4 100644 --- a/spec/frontend/authentication/u2f/authenticate_spec.js +++ b/spec/frontend/authentication/u2f/authenticate_spec.js @@ -36,24 +36,19 @@ describe('U2FAuthenticate', () => { window.u2f = oldu2f; }); - it('falls back to normal 2fa', (done) => { - component - .start() - .then(() => { - expect(component.switchToFallbackUI).toHaveBeenCalled(); - done(); - }) - .catch(done.fail); + it('falls back to normal 2fa', async () => { + await component.start(); + expect(component.switchToFallbackUI).toHaveBeenCalled(); }); }); describe('with u2f available', () => { - beforeEach((done) => { + beforeEach(() => { // bypass automatic form submission within renderAuthenticated jest.spyOn(component, 'renderAuthenticated').mockReturnValue(true); u2fDevice = new MockU2FDevice(); - component.start().then(done).catch(done.fail); + return component.start(); }); it('allows authenticating via a U2F device', () => { diff --git a/spec/frontend/authentication/u2f/register_spec.js b/spec/frontend/authentication/u2f/register_spec.js index a814144ac7a..810396aa9fd 100644 --- a/spec/frontend/authentication/u2f/register_spec.js +++ b/spec/frontend/authentication/u2f/register_spec.js @@ -8,12 +8,12 @@ describe('U2FRegister', () => { let container; let component; - beforeEach((done) => { + beforeEach(() => { loadFixtures('u2f/register.html'); u2fDevice = new MockU2FDevice(); container = $('#js-register-token-2fa'); component = new U2FRegister(container, {}); - component.start().then(done).catch(done.fail); + return component.start(); }); it('allows registering a U2F device', () => { diff --git a/spec/frontend/badges/components/badge_spec.js b/spec/frontend/badges/components/badge_spec.js index 2310fb8bd8e..fe4cf8ce8eb 100644 --- a/spec/frontend/badges/components/badge_spec.js +++ b/spec/frontend/badges/components/badge_spec.js @@ -89,11 +89,9 @@ describe('Badge component', () => { }); describe('behavior', () => { - beforeEach((done) => { + beforeEach(() => { setFixtures('<div id="dummy-element"></div>'); - createComponent({ ...dummyProps }, '#dummy-element') - .then(done) - .catch(done.fail); + return createComponent({ ...dummyProps }, '#dummy-element'); }); it('shows a badge image after loading', () => { diff --git a/spec/frontend/badges/store/actions_spec.js b/spec/frontend/badges/store/actions_spec.js index 75699f24463..02e1b8e65e4 100644 --- a/spec/frontend/badges/store/actions_spec.js +++ b/spec/frontend/badges/store/actions_spec.js @@ -33,41 +33,38 @@ describe('Badges store actions', () => { }); describe('requestNewBadge', () => { - it('commits REQUEST_NEW_BADGE', (done) => { - testAction( + it('commits REQUEST_NEW_BADGE', () => { + return testAction( actions.requestNewBadge, null, state, [{ type: mutationTypes.REQUEST_NEW_BADGE }], [], - done, ); }); }); describe('receiveNewBadge', () => { - it('commits RECEIVE_NEW_BADGE', (done) => { + it('commits RECEIVE_NEW_BADGE', () => { const newBadge = createDummyBadge(); - testAction( + return testAction( actions.receiveNewBadge, newBadge, state, [{ type: mutationTypes.RECEIVE_NEW_BADGE, payload: newBadge }], [], - done, ); }); }); describe('receiveNewBadgeError', () => { - it('commits RECEIVE_NEW_BADGE_ERROR', (done) => { - testAction( + it('commits RECEIVE_NEW_BADGE_ERROR', () => { + return testAction( actions.receiveNewBadgeError, null, state, [{ type: mutationTypes.RECEIVE_NEW_BADGE_ERROR }], [], - done, ); }); }); @@ -87,7 +84,7 @@ describe('Badges store actions', () => { }; }); - it('dispatches requestNewBadge and receiveNewBadge for successful response', (done) => { + it('dispatches requestNewBadge and receiveNewBadge for successful response', async () => { const dummyResponse = createDummyBadgeResponse(); endpointMock.replyOnce((req) => { @@ -105,16 +102,12 @@ describe('Badges store actions', () => { }); const dummyBadge = transformBackendBadge(dummyResponse); - actions - .addBadge({ state, dispatch }) - .then(() => { - expect(dispatch.mock.calls).toEqual([['receiveNewBadge', dummyBadge]]); - }) - .then(done) - .catch(done.fail); + + await actions.addBadge({ state, dispatch }); + expect(dispatch.mock.calls).toEqual([['receiveNewBadge', dummyBadge]]); }); - it('dispatches requestNewBadge and receiveNewBadgeError for error response', (done) => { + it('dispatches requestNewBadge and receiveNewBadgeError for error response', async () => { endpointMock.replyOnce((req) => { expect(req.data).toBe( JSON.stringify({ @@ -129,52 +122,43 @@ describe('Badges store actions', () => { return [500, '']; }); - actions - .addBadge({ state, dispatch }) - .then(() => done.fail('Expected Ajax call to fail!')) - .catch(() => { - expect(dispatch.mock.calls).toEqual([['receiveNewBadgeError']]); - }) - .then(done) - .catch(done.fail); + await expect(actions.addBadge({ state, dispatch })).rejects.toThrow(); + expect(dispatch.mock.calls).toEqual([['receiveNewBadgeError']]); }); }); describe('requestDeleteBadge', () => { - it('commits REQUEST_DELETE_BADGE', (done) => { - testAction( + it('commits REQUEST_DELETE_BADGE', () => { + return testAction( actions.requestDeleteBadge, badgeId, state, [{ type: mutationTypes.REQUEST_DELETE_BADGE, payload: badgeId }], [], - done, ); }); }); describe('receiveDeleteBadge', () => { - it('commits RECEIVE_DELETE_BADGE', (done) => { - testAction( + it('commits RECEIVE_DELETE_BADGE', () => { + return testAction( actions.receiveDeleteBadge, badgeId, state, [{ type: mutationTypes.RECEIVE_DELETE_BADGE, payload: badgeId }], [], - done, ); }); }); describe('receiveDeleteBadgeError', () => { - it('commits RECEIVE_DELETE_BADGE_ERROR', (done) => { - testAction( + it('commits RECEIVE_DELETE_BADGE_ERROR', () => { + return testAction( actions.receiveDeleteBadgeError, badgeId, state, [{ type: mutationTypes.RECEIVE_DELETE_BADGE_ERROR, payload: badgeId }], [], - done, ); }); }); @@ -188,91 +172,76 @@ describe('Badges store actions', () => { dispatch = jest.fn(); }); - it('dispatches requestDeleteBadge and receiveDeleteBadge for successful response', (done) => { + it('dispatches requestDeleteBadge and receiveDeleteBadge for successful response', async () => { endpointMock.replyOnce(() => { expect(dispatch.mock.calls).toEqual([['requestDeleteBadge', badgeId]]); dispatch.mockClear(); return [200, '']; }); - actions - .deleteBadge({ state, dispatch }, { id: badgeId }) - .then(() => { - expect(dispatch.mock.calls).toEqual([['receiveDeleteBadge', badgeId]]); - }) - .then(done) - .catch(done.fail); + await actions.deleteBadge({ state, dispatch }, { id: badgeId }); + expect(dispatch.mock.calls).toEqual([['receiveDeleteBadge', badgeId]]); }); - it('dispatches requestDeleteBadge and receiveDeleteBadgeError for error response', (done) => { + it('dispatches requestDeleteBadge and receiveDeleteBadgeError for error response', async () => { endpointMock.replyOnce(() => { expect(dispatch.mock.calls).toEqual([['requestDeleteBadge', badgeId]]); dispatch.mockClear(); return [500, '']; }); - actions - .deleteBadge({ state, dispatch }, { id: badgeId }) - .then(() => done.fail('Expected Ajax call to fail!')) - .catch(() => { - expect(dispatch.mock.calls).toEqual([['receiveDeleteBadgeError', badgeId]]); - }) - .then(done) - .catch(done.fail); + await expect(actions.deleteBadge({ state, dispatch }, { id: badgeId })).rejects.toThrow(); + expect(dispatch.mock.calls).toEqual([['receiveDeleteBadgeError', badgeId]]); }); }); describe('editBadge', () => { - it('commits START_EDITING', (done) => { + it('commits START_EDITING', () => { const dummyBadge = createDummyBadge(); - testAction( + return testAction( actions.editBadge, dummyBadge, state, [{ type: mutationTypes.START_EDITING, payload: dummyBadge }], [], - done, ); }); }); describe('requestLoadBadges', () => { - it('commits REQUEST_LOAD_BADGES', (done) => { + it('commits REQUEST_LOAD_BADGES', () => { const dummyData = 'this is not real data'; - testAction( + return testAction( actions.requestLoadBadges, dummyData, state, [{ type: mutationTypes.REQUEST_LOAD_BADGES, payload: dummyData }], [], - done, ); }); }); describe('receiveLoadBadges', () => { - it('commits RECEIVE_LOAD_BADGES', (done) => { + it('commits RECEIVE_LOAD_BADGES', () => { const badges = dummyBadges; - testAction( + return testAction( actions.receiveLoadBadges, badges, state, [{ type: mutationTypes.RECEIVE_LOAD_BADGES, payload: badges }], [], - done, ); }); }); describe('receiveLoadBadgesError', () => { - it('commits RECEIVE_LOAD_BADGES_ERROR', (done) => { - testAction( + it('commits RECEIVE_LOAD_BADGES_ERROR', () => { + return testAction( actions.receiveLoadBadgesError, null, state, [{ type: mutationTypes.RECEIVE_LOAD_BADGES_ERROR }], [], - done, ); }); }); @@ -286,7 +255,7 @@ describe('Badges store actions', () => { dispatch = jest.fn(); }); - it('dispatches requestLoadBadges and receiveLoadBadges for successful response', (done) => { + it('dispatches requestLoadBadges and receiveLoadBadges for successful response', async () => { const dummyData = 'this is just some data'; const dummyReponse = [ createDummyBadgeResponse(), @@ -299,18 +268,13 @@ describe('Badges store actions', () => { return [200, dummyReponse]; }); - actions - .loadBadges({ state, dispatch }, dummyData) - .then(() => { - const badges = dummyReponse.map(transformBackendBadge); + await actions.loadBadges({ state, dispatch }, dummyData); + const badges = dummyReponse.map(transformBackendBadge); - expect(dispatch.mock.calls).toEqual([['receiveLoadBadges', badges]]); - }) - .then(done) - .catch(done.fail); + expect(dispatch.mock.calls).toEqual([['receiveLoadBadges', badges]]); }); - it('dispatches requestLoadBadges and receiveLoadBadgesError for error response', (done) => { + it('dispatches requestLoadBadges and receiveLoadBadgesError for error response', async () => { const dummyData = 'this is just some data'; endpointMock.replyOnce(() => { expect(dispatch.mock.calls).toEqual([['requestLoadBadges', dummyData]]); @@ -318,53 +282,44 @@ describe('Badges store actions', () => { return [500, '']; }); - actions - .loadBadges({ state, dispatch }, dummyData) - .then(() => done.fail('Expected Ajax call to fail!')) - .catch(() => { - expect(dispatch.mock.calls).toEqual([['receiveLoadBadgesError']]); - }) - .then(done) - .catch(done.fail); + await expect(actions.loadBadges({ state, dispatch }, dummyData)).rejects.toThrow(); + expect(dispatch.mock.calls).toEqual([['receiveLoadBadgesError']]); }); }); describe('requestRenderedBadge', () => { - it('commits REQUEST_RENDERED_BADGE', (done) => { - testAction( + it('commits REQUEST_RENDERED_BADGE', () => { + return testAction( actions.requestRenderedBadge, null, state, [{ type: mutationTypes.REQUEST_RENDERED_BADGE }], [], - done, ); }); }); describe('receiveRenderedBadge', () => { - it('commits RECEIVE_RENDERED_BADGE', (done) => { + it('commits RECEIVE_RENDERED_BADGE', () => { const dummyBadge = createDummyBadge(); - testAction( + return testAction( actions.receiveRenderedBadge, dummyBadge, state, [{ type: mutationTypes.RECEIVE_RENDERED_BADGE, payload: dummyBadge }], [], - done, ); }); }); describe('receiveRenderedBadgeError', () => { - it('commits RECEIVE_RENDERED_BADGE_ERROR', (done) => { - testAction( + it('commits RECEIVE_RENDERED_BADGE_ERROR', () => { + return testAction( actions.receiveRenderedBadgeError, null, state, [{ type: mutationTypes.RECEIVE_RENDERED_BADGE_ERROR }], [], - done, ); }); }); @@ -388,56 +343,41 @@ describe('Badges store actions', () => { dispatch = jest.fn(); }); - it('returns immediately if imageUrl is empty', (done) => { + it('returns immediately if imageUrl is empty', async () => { jest.spyOn(axios, 'get').mockImplementation(() => {}); badgeInForm.imageUrl = ''; - actions - .renderBadge({ state, dispatch }) - .then(() => { - expect(axios.get).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + await actions.renderBadge({ state, dispatch }); + expect(axios.get).not.toHaveBeenCalled(); }); - it('returns immediately if linkUrl is empty', (done) => { + it('returns immediately if linkUrl is empty', async () => { jest.spyOn(axios, 'get').mockImplementation(() => {}); badgeInForm.linkUrl = ''; - actions - .renderBadge({ state, dispatch }) - .then(() => { - expect(axios.get).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + await actions.renderBadge({ state, dispatch }); + expect(axios.get).not.toHaveBeenCalled(); }); - it('escapes user input', (done) => { + it('escapes user input', async () => { jest .spyOn(axios, 'get') .mockImplementation(() => Promise.resolve({ data: createDummyBadgeResponse() })); badgeInForm.imageUrl = '&make-sandwich=true'; badgeInForm.linkUrl = '<script>I am dangerous!</script>'; - actions - .renderBadge({ state, dispatch }) - .then(() => { - expect(axios.get.mock.calls.length).toBe(1); - const url = axios.get.mock.calls[0][0]; + await actions.renderBadge({ state, dispatch }); + expect(axios.get.mock.calls.length).toBe(1); + const url = axios.get.mock.calls[0][0]; - expect(url).toMatch(new RegExp(`^${dummyEndpointUrl}/render?`)); - expect(url).toMatch( - new RegExp('\\?link_url=%3Cscript%3EI%20am%20dangerous!%3C%2Fscript%3E&'), - ); - expect(url).toMatch(new RegExp('&image_url=%26make-sandwich%3Dtrue$')); - }) - .then(done) - .catch(done.fail); + expect(url).toMatch(new RegExp(`^${dummyEndpointUrl}/render?`)); + expect(url).toMatch( + new RegExp('\\?link_url=%3Cscript%3EI%20am%20dangerous!%3C%2Fscript%3E&'), + ); + expect(url).toMatch(new RegExp('&image_url=%26make-sandwich%3Dtrue$')); }); - it('dispatches requestRenderedBadge and receiveRenderedBadge for successful response', (done) => { + it('dispatches requestRenderedBadge and receiveRenderedBadge for successful response', async () => { const dummyReponse = createDummyBadgeResponse(); endpointMock.replyOnce(() => { expect(dispatch.mock.calls).toEqual([['requestRenderedBadge']]); @@ -445,71 +385,57 @@ describe('Badges store actions', () => { return [200, dummyReponse]; }); - actions - .renderBadge({ state, dispatch }) - .then(() => { - const renderedBadge = transformBackendBadge(dummyReponse); + await actions.renderBadge({ state, dispatch }); + const renderedBadge = transformBackendBadge(dummyReponse); - expect(dispatch.mock.calls).toEqual([['receiveRenderedBadge', renderedBadge]]); - }) - .then(done) - .catch(done.fail); + expect(dispatch.mock.calls).toEqual([['receiveRenderedBadge', renderedBadge]]); }); - it('dispatches requestRenderedBadge and receiveRenderedBadgeError for error response', (done) => { + it('dispatches requestRenderedBadge and receiveRenderedBadgeError for error response', async () => { endpointMock.replyOnce(() => { expect(dispatch.mock.calls).toEqual([['requestRenderedBadge']]); dispatch.mockClear(); return [500, '']; }); - actions - .renderBadge({ state, dispatch }) - .then(() => done.fail('Expected Ajax call to fail!')) - .catch(() => { - expect(dispatch.mock.calls).toEqual([['receiveRenderedBadgeError']]); - }) - .then(done) - .catch(done.fail); + await expect(actions.renderBadge({ state, dispatch })).rejects.toThrow(); + expect(dispatch.mock.calls).toEqual([['receiveRenderedBadgeError']]); }); }); describe('requestUpdatedBadge', () => { - it('commits REQUEST_UPDATED_BADGE', (done) => { - testAction( + it('commits REQUEST_UPDATED_BADGE', () => { + return testAction( actions.requestUpdatedBadge, null, state, [{ type: mutationTypes.REQUEST_UPDATED_BADGE }], [], - done, ); }); }); describe('receiveUpdatedBadge', () => { - it('commits RECEIVE_UPDATED_BADGE', (done) => { + it('commits RECEIVE_UPDATED_BADGE', () => { const updatedBadge = createDummyBadge(); - testAction( + return testAction( actions.receiveUpdatedBadge, updatedBadge, state, [{ type: mutationTypes.RECEIVE_UPDATED_BADGE, payload: updatedBadge }], [], - done, ); }); }); describe('receiveUpdatedBadgeError', () => { - it('commits RECEIVE_UPDATED_BADGE_ERROR', (done) => { - testAction( + it('commits RECEIVE_UPDATED_BADGE_ERROR', () => { + return testAction( actions.receiveUpdatedBadgeError, null, state, [{ type: mutationTypes.RECEIVE_UPDATED_BADGE_ERROR }], [], - done, ); }); }); @@ -529,7 +455,7 @@ describe('Badges store actions', () => { dispatch = jest.fn(); }); - it('dispatches requestUpdatedBadge and receiveUpdatedBadge for successful response', (done) => { + it('dispatches requestUpdatedBadge and receiveUpdatedBadge for successful response', async () => { const dummyResponse = createDummyBadgeResponse(); endpointMock.replyOnce((req) => { @@ -547,16 +473,11 @@ describe('Badges store actions', () => { }); const updatedBadge = transformBackendBadge(dummyResponse); - actions - .saveBadge({ state, dispatch }) - .then(() => { - expect(dispatch.mock.calls).toEqual([['receiveUpdatedBadge', updatedBadge]]); - }) - .then(done) - .catch(done.fail); + await actions.saveBadge({ state, dispatch }); + expect(dispatch.mock.calls).toEqual([['receiveUpdatedBadge', updatedBadge]]); }); - it('dispatches requestUpdatedBadge and receiveUpdatedBadgeError for error response', (done) => { + it('dispatches requestUpdatedBadge and receiveUpdatedBadgeError for error response', async () => { endpointMock.replyOnce((req) => { expect(req.data).toBe( JSON.stringify({ @@ -571,53 +492,44 @@ describe('Badges store actions', () => { return [500, '']; }); - actions - .saveBadge({ state, dispatch }) - .then(() => done.fail('Expected Ajax call to fail!')) - .catch(() => { - expect(dispatch.mock.calls).toEqual([['receiveUpdatedBadgeError']]); - }) - .then(done) - .catch(done.fail); + await expect(actions.saveBadge({ state, dispatch })).rejects.toThrow(); + expect(dispatch.mock.calls).toEqual([['receiveUpdatedBadgeError']]); }); }); describe('stopEditing', () => { - it('commits STOP_EDITING', (done) => { - testAction( + it('commits STOP_EDITING', () => { + return testAction( actions.stopEditing, null, state, [{ type: mutationTypes.STOP_EDITING }], [], - done, ); }); }); describe('updateBadgeInForm', () => { - it('commits UPDATE_BADGE_IN_FORM', (done) => { + it('commits UPDATE_BADGE_IN_FORM', () => { const dummyBadge = createDummyBadge(); - testAction( + return testAction( actions.updateBadgeInForm, dummyBadge, state, [{ type: mutationTypes.UPDATE_BADGE_IN_FORM, payload: dummyBadge }], [], - done, ); }); describe('updateBadgeInModal', () => { - it('commits UPDATE_BADGE_IN_MODAL', (done) => { + it('commits UPDATE_BADGE_IN_MODAL', () => { const dummyBadge = createDummyBadge(); - testAction( + return testAction( actions.updateBadgeInModal, dummyBadge, state, [{ type: mutationTypes.UPDATE_BADGE_IN_MODAL, payload: dummyBadge }], [], - done, ); }); }); 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 b0e9e5dd00b..e9535d8cc12 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 @@ -29,53 +29,56 @@ describe('Batch comments store actions', () => { }); describe('addDraftToDiscussion', () => { - it('commits ADD_NEW_DRAFT if no errors returned', (done) => { + it('commits ADD_NEW_DRAFT if no errors returned', () => { res = { id: 1 }; mock.onAny().reply(200, res); - testAction( + return testAction( actions.addDraftToDiscussion, { endpoint: TEST_HOST, data: 'test' }, null, [{ type: 'ADD_NEW_DRAFT', payload: res }], [], - done, ); }); - it('does not commit ADD_NEW_DRAFT if errors returned', (done) => { + it('does not commit ADD_NEW_DRAFT if errors returned', () => { mock.onAny().reply(500); - testAction( + return testAction( actions.addDraftToDiscussion, { endpoint: TEST_HOST, data: 'test' }, null, [], [], - done, ); }); }); describe('createNewDraft', () => { - it('commits ADD_NEW_DRAFT if no errors returned', (done) => { + it('commits ADD_NEW_DRAFT if no errors returned', () => { res = { id: 1 }; mock.onAny().reply(200, res); - testAction( + return testAction( actions.createNewDraft, { endpoint: TEST_HOST, data: 'test' }, null, [{ type: 'ADD_NEW_DRAFT', payload: res }], [], - done, ); }); - it('does not commit ADD_NEW_DRAFT if errors returned', (done) => { + it('does not commit ADD_NEW_DRAFT if errors returned', () => { mock.onAny().reply(500); - testAction(actions.createNewDraft, { endpoint: TEST_HOST, data: 'test' }, null, [], [], done); + return testAction( + actions.createNewDraft, + { endpoint: TEST_HOST, data: 'test' }, + null, + [], + [], + ); }); }); @@ -90,7 +93,7 @@ describe('Batch comments store actions', () => { }; }); - it('commits DELETE_DRAFT if no errors returned', (done) => { + it('commits DELETE_DRAFT if no errors returned', () => { const commit = jest.fn(); const context = { getters, @@ -99,16 +102,12 @@ describe('Batch comments store actions', () => { res = { id: 1 }; mock.onAny().reply(200); - actions - .deleteDraft(context, { id: 1 }) - .then(() => { - expect(commit).toHaveBeenCalledWith('DELETE_DRAFT', 1); - }) - .then(done) - .catch(done.fail); + return actions.deleteDraft(context, { id: 1 }).then(() => { + expect(commit).toHaveBeenCalledWith('DELETE_DRAFT', 1); + }); }); - it('does not commit DELETE_DRAFT if errors returned', (done) => { + it('does not commit DELETE_DRAFT if errors returned', () => { const commit = jest.fn(); const context = { getters, @@ -116,13 +115,9 @@ describe('Batch comments store actions', () => { }; mock.onAny().reply(500); - actions - .deleteDraft(context, { id: 1 }) - .then(() => { - expect(commit).not.toHaveBeenCalledWith('DELETE_DRAFT', 1); - }) - .then(done) - .catch(done.fail); + return actions.deleteDraft(context, { id: 1 }).then(() => { + expect(commit).not.toHaveBeenCalledWith('DELETE_DRAFT', 1); + }); }); }); @@ -137,7 +132,7 @@ describe('Batch comments store actions', () => { }; }); - it('commits SET_BATCH_COMMENTS_DRAFTS with returned data', (done) => { + it('commits SET_BATCH_COMMENTS_DRAFTS with returned data', () => { const commit = jest.fn(); const dispatch = jest.fn(); const context = { @@ -151,14 +146,10 @@ describe('Batch comments store actions', () => { res = { id: 1 }; mock.onAny().reply(200, res); - actions - .fetchDrafts(context) - .then(() => { - expect(commit).toHaveBeenCalledWith('SET_BATCH_COMMENTS_DRAFTS', { id: 1 }); - expect(dispatch).toHaveBeenCalledWith('convertToDiscussion', '1', { root: true }); - }) - .then(done) - .catch(done.fail); + return actions.fetchDrafts(context).then(() => { + expect(commit).toHaveBeenCalledWith('SET_BATCH_COMMENTS_DRAFTS', { id: 1 }); + expect(dispatch).toHaveBeenCalledWith('convertToDiscussion', '1', { root: true }); + }); }); }); @@ -177,32 +168,24 @@ describe('Batch comments store actions', () => { rootGetters = { discussionsStructuredByLineCode: 'discussions' }; }); - it('dispatches actions & commits', (done) => { + it('dispatches actions & commits', () => { mock.onAny().reply(200); - actions - .publishReview({ dispatch, commit, getters, rootGetters }) - .then(() => { - expect(commit.mock.calls[0]).toEqual(['REQUEST_PUBLISH_REVIEW']); - expect(commit.mock.calls[1]).toEqual(['RECEIVE_PUBLISH_REVIEW_SUCCESS']); + return actions.publishReview({ dispatch, commit, getters, rootGetters }).then(() => { + expect(commit.mock.calls[0]).toEqual(['REQUEST_PUBLISH_REVIEW']); + expect(commit.mock.calls[1]).toEqual(['RECEIVE_PUBLISH_REVIEW_SUCCESS']); - expect(dispatch.mock.calls[0]).toEqual(['updateDiscussionsAfterPublish']); - }) - .then(done) - .catch(done.fail); + expect(dispatch.mock.calls[0]).toEqual(['updateDiscussionsAfterPublish']); + }); }); - it('dispatches error commits', (done) => { + it('dispatches error commits', () => { mock.onAny().reply(500); - actions - .publishReview({ dispatch, commit, getters, rootGetters }) - .then(() => { - expect(commit.mock.calls[0]).toEqual(['REQUEST_PUBLISH_REVIEW']); - expect(commit.mock.calls[1]).toEqual(['RECEIVE_PUBLISH_REVIEW_ERROR']); - }) - .then(done) - .catch(done.fail); + return actions.publishReview({ dispatch, commit, getters, rootGetters }).then(() => { + expect(commit.mock.calls[0]).toEqual(['REQUEST_PUBLISH_REVIEW']); + expect(commit.mock.calls[1]).toEqual(['RECEIVE_PUBLISH_REVIEW_ERROR']); + }); }); }); @@ -262,7 +245,7 @@ describe('Batch comments store actions', () => { }); describe('expandAllDiscussions', () => { - it('dispatches expandDiscussion for all drafts', (done) => { + it('dispatches expandDiscussion for all drafts', () => { const state = { drafts: [ { @@ -271,7 +254,7 @@ describe('Batch comments store actions', () => { ], }; - testAction( + return testAction( actions.expandAllDiscussions, null, state, @@ -282,7 +265,6 @@ describe('Batch comments store actions', () => { payload: { discussionId: '1' }, }, ], - done, ); }); }); diff --git a/spec/frontend/behaviors/gl_emoji_spec.js b/spec/frontend/behaviors/gl_emoji_spec.js index cac1ea67cf5..8842ad636ec 100644 --- a/spec/frontend/behaviors/gl_emoji_spec.js +++ b/spec/frontend/behaviors/gl_emoji_spec.js @@ -77,6 +77,12 @@ describe('gl_emoji', () => { '<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament">❔</gl-emoji>', `<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament"><img class="emoji" title=":grey_question:" alt=":grey_question:" src="/-/emojis/${EMOJI_VERSION}/grey_question.png" width="20" height="20" align="absmiddle"></gl-emoji>`, ], + [ + 'custom emoji with image fallback', + '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"></gl-emoji>', + '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"><img class="emoji" title=":party-parrot:" alt=":party-parrot:" src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" width="20" height="20" align="absmiddle"></gl-emoji>', + '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"><img class="emoji" title=":party-parrot:" alt=":party-parrot:" src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" width="20" height="20" align="absmiddle"></gl-emoji>', + ], ])('%s', (name, markup, withEmojiSupport, withoutEmojiSupport) => { it(`renders correctly with emoji support`, async () => { jest.spyOn(EmojiUnicodeSupport, 'default').mockReturnValue(true); diff --git a/spec/frontend/blob/balsamiq/balsamiq_viewer_spec.js b/spec/frontend/blob/balsamiq/balsamiq_viewer_spec.js deleted file mode 100644 index d7531d15b9a..00000000000 --- a/spec/frontend/blob/balsamiq/balsamiq_viewer_spec.js +++ /dev/null @@ -1,363 +0,0 @@ -import sqljs from 'sql.js'; -import ClassSpecHelper from 'helpers/class_spec_helper'; -import BalsamiqViewer from '~/blob/balsamiq/balsamiq_viewer'; -import axios from '~/lib/utils/axios_utils'; - -jest.mock('sql.js'); - -describe('BalsamiqViewer', () => { - const mockArrayBuffer = new ArrayBuffer(10); - let balsamiqViewer; - let viewer; - - describe('class constructor', () => { - beforeEach(() => { - viewer = {}; - - balsamiqViewer = new BalsamiqViewer(viewer); - }); - - it('should set .viewer', () => { - expect(balsamiqViewer.viewer).toBe(viewer); - }); - }); - - describe('loadFile', () => { - let bv; - const endpoint = 'endpoint'; - const requestSuccess = Promise.resolve({ - data: mockArrayBuffer, - status: 200, - }); - - beforeEach(() => { - viewer = {}; - bv = new BalsamiqViewer(viewer); - }); - - it('should call `axios.get` on `endpoint` param with responseType set to `arraybuffer', () => { - jest.spyOn(axios, 'get').mockReturnValue(requestSuccess); - jest.spyOn(bv, 'renderFile').mockReturnValue(); - - bv.loadFile(endpoint); - - expect(axios.get).toHaveBeenCalledWith( - endpoint, - expect.objectContaining({ - responseType: 'arraybuffer', - }), - ); - }); - - it('should call `renderFile` on request success', (done) => { - jest.spyOn(axios, 'get').mockReturnValue(requestSuccess); - jest.spyOn(bv, 'renderFile').mockImplementation(() => {}); - - bv.loadFile(endpoint) - .then(() => { - expect(bv.renderFile).toHaveBeenCalledWith(mockArrayBuffer); - }) - .then(done) - .catch(done.fail); - }); - - it('should not call `renderFile` on request failure', (done) => { - jest.spyOn(axios, 'get').mockReturnValue(Promise.reject()); - jest.spyOn(bv, 'renderFile').mockImplementation(() => {}); - - bv.loadFile(endpoint) - .then(() => { - done.fail('Expected loadFile to throw error!'); - }) - .catch(() => { - expect(bv.renderFile).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); - }); - }); - - describe('renderFile', () => { - let container; - let previews; - - beforeEach(() => { - viewer = { - appendChild: jest.fn(), - }; - previews = [document.createElement('ul'), document.createElement('ul')]; - - balsamiqViewer = { - initDatabase: jest.fn(), - getPreviews: jest.fn(), - renderPreview: jest.fn(), - }; - balsamiqViewer.viewer = viewer; - - balsamiqViewer.getPreviews.mockReturnValue(previews); - balsamiqViewer.renderPreview.mockImplementation((preview) => preview); - viewer.appendChild.mockImplementation((containerElement) => { - container = containerElement; - }); - - BalsamiqViewer.prototype.renderFile.call(balsamiqViewer, mockArrayBuffer); - }); - - it('should call .initDatabase', () => { - expect(balsamiqViewer.initDatabase).toHaveBeenCalledWith(mockArrayBuffer); - }); - - it('should call .getPreviews', () => { - expect(balsamiqViewer.getPreviews).toHaveBeenCalled(); - }); - - it('should call .renderPreview for each preview', () => { - const allArgs = balsamiqViewer.renderPreview.mock.calls; - - expect(allArgs.length).toBe(2); - - previews.forEach((preview, i) => { - expect(allArgs[i][0]).toBe(preview); - }); - }); - - it('should set the container HTML', () => { - expect(container.innerHTML).toBe('<ul></ul><ul></ul>'); - }); - - it('should add inline preview classes', () => { - expect(container.classList[0]).toBe('list-inline'); - expect(container.classList[1]).toBe('previews'); - }); - - it('should call viewer.appendChild', () => { - expect(viewer.appendChild).toHaveBeenCalledWith(container); - }); - }); - - describe('initDatabase', () => { - let uint8Array; - let data; - - beforeEach(() => { - uint8Array = {}; - data = 'data'; - balsamiqViewer = {}; - window.Uint8Array = jest.fn(); - window.Uint8Array.mockReturnValue(uint8Array); - - BalsamiqViewer.prototype.initDatabase.call(balsamiqViewer, data); - }); - - it('should instantiate Uint8Array', () => { - expect(window.Uint8Array).toHaveBeenCalledWith(data); - }); - - it('should call sqljs.Database', () => { - expect(sqljs.Database).toHaveBeenCalledWith(uint8Array); - }); - - it('should set .database', () => { - expect(balsamiqViewer.database).not.toBe(null); - }); - }); - - describe('getPreviews', () => { - let database; - let thumbnails; - let getPreviews; - - beforeEach(() => { - database = { - exec: jest.fn(), - }; - thumbnails = [{ values: [0, 1, 2] }]; - - balsamiqViewer = { - database, - }; - - jest - .spyOn(BalsamiqViewer, 'parsePreview') - .mockImplementation((preview) => preview.toString()); - database.exec.mockReturnValue(thumbnails); - - getPreviews = BalsamiqViewer.prototype.getPreviews.call(balsamiqViewer); - }); - - it('should call database.exec', () => { - expect(database.exec).toHaveBeenCalledWith('SELECT * FROM thumbnails'); - }); - - it('should call .parsePreview for each value', () => { - const allArgs = BalsamiqViewer.parsePreview.mock.calls; - - expect(allArgs.length).toBe(3); - - thumbnails[0].values.forEach((value, i) => { - expect(allArgs[i][0]).toBe(value); - }); - }); - - it('should return an array of parsed values', () => { - expect(getPreviews).toEqual(['0', '1', '2']); - }); - }); - - describe('getResource', () => { - let database; - let resourceID; - let resource; - let getResource; - - beforeEach(() => { - database = { - exec: jest.fn(), - }; - resourceID = 4; - resource = ['resource']; - - balsamiqViewer = { - database, - }; - - database.exec.mockReturnValue(resource); - - getResource = BalsamiqViewer.prototype.getResource.call(balsamiqViewer, resourceID); - }); - - it('should call database.exec', () => { - expect(database.exec).toHaveBeenCalledWith( - `SELECT * FROM resources WHERE id = '${resourceID}'`, - ); - }); - - it('should return the selected resource', () => { - expect(getResource).toBe(resource[0]); - }); - }); - - describe('renderPreview', () => { - let previewElement; - let innerHTML; - let preview; - let renderPreview; - - beforeEach(() => { - innerHTML = '<a>innerHTML</a>'; - previewElement = { - outerHTML: '<p>outerHTML</p>', - classList: { - add: jest.fn(), - }, - }; - preview = {}; - - balsamiqViewer = { - renderTemplate: jest.fn(), - }; - - jest.spyOn(document, 'createElement').mockReturnValue(previewElement); - balsamiqViewer.renderTemplate.mockReturnValue(innerHTML); - - renderPreview = BalsamiqViewer.prototype.renderPreview.call(balsamiqViewer, preview); - }); - - it('should call classList.add', () => { - expect(previewElement.classList.add).toHaveBeenCalledWith('preview'); - }); - - it('should call .renderTemplate', () => { - expect(balsamiqViewer.renderTemplate).toHaveBeenCalledWith(preview); - }); - - it('should set .innerHTML', () => { - expect(previewElement.innerHTML).toBe(innerHTML); - }); - - it('should return element', () => { - expect(renderPreview).toBe(previewElement); - }); - }); - - describe('renderTemplate', () => { - let preview; - let name; - let resource; - let template; - let renderTemplate; - - beforeEach(() => { - preview = { resourceID: 1, image: 'image' }; - name = 'name'; - resource = 'resource'; - template = ` - <div class="card"> - <div class="card-header">name</div> - <div class="card-body"> - <img class="img-thumbnail" src="data:image/png;base64,image"/> - </div> - </div> - `; - - balsamiqViewer = { - getResource: jest.fn(), - }; - - jest.spyOn(BalsamiqViewer, 'parseTitle').mockReturnValue(name); - balsamiqViewer.getResource.mockReturnValue(resource); - - renderTemplate = BalsamiqViewer.prototype.renderTemplate.call(balsamiqViewer, preview); - }); - - it('should call .getResource', () => { - expect(balsamiqViewer.getResource).toHaveBeenCalledWith(preview.resourceID); - }); - - it('should call .parseTitle', () => { - expect(BalsamiqViewer.parseTitle).toHaveBeenCalledWith(resource); - }); - - it('should return the template string', () => { - expect(renderTemplate.replace(/\s/g, '')).toEqual(template.replace(/\s/g, '')); - }); - }); - - describe('parsePreview', () => { - let preview; - let parsePreview; - - beforeEach(() => { - preview = ['{}', '{ "id": 1 }']; - - jest.spyOn(JSON, 'parse'); - - parsePreview = BalsamiqViewer.parsePreview(preview); - }); - - ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview'); - - it('should return the parsed JSON', () => { - expect(parsePreview).toEqual(JSON.parse('{ "id": 1 }')); - }); - }); - - describe('parseTitle', () => { - let title; - let parseTitle; - - beforeEach(() => { - title = { values: [['{}', '{}', '{"name":"name"}']] }; - - jest.spyOn(JSON, 'parse'); - - parseTitle = BalsamiqViewer.parseTitle(title); - }); - - ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview'); - - it('should return the name value', () => { - expect(parseTitle).toBe('name'); - }); - }); -}); diff --git a/spec/frontend/boards/boards_util_spec.js b/spec/frontend/boards/boards_util_spec.js index d45b6e35a45..ab3cf072357 100644 --- a/spec/frontend/boards/boards_util_spec.js +++ b/spec/frontend/boards/boards_util_spec.js @@ -1,6 +1,12 @@ import { formatIssueInput, filterVariables } from '~/boards/boards_util'; describe('formatIssueInput', () => { + const issueInput = { + labelIds: ['gid://gitlab/GroupLabel/5'], + projectPath: 'gitlab-org/gitlab-test', + id: 'gid://gitlab/Issue/11', + }; + it('correctly merges boardConfig into the issue', () => { const boardConfig = { labels: [ @@ -14,12 +20,6 @@ describe('formatIssueInput', () => { weight: 1, }; - const issueInput = { - labelIds: ['gid://gitlab/GroupLabel/5'], - projectPath: 'gitlab-org/gitlab-test', - id: 'gid://gitlab/Issue/11', - }; - const result = formatIssueInput(issueInput, boardConfig); expect(result).toEqual({ projectPath: 'gitlab-org/gitlab-test', @@ -27,8 +27,26 @@ describe('formatIssueInput', () => { labelIds: ['gid://gitlab/GroupLabel/5', 'gid://gitlab/GroupLabel/44'], assigneeIds: ['gid://gitlab/User/55'], milestoneId: 'gid://gitlab/Milestone/66', + weight: 1, }); }); + + it('does not add weight to input if weight is NONE', () => { + const boardConfig = { + weight: -2, // NO_WEIGHT + }; + + const result = formatIssueInput(issueInput, boardConfig); + const expected = { + projectPath: 'gitlab-org/gitlab-test', + id: 'gid://gitlab/Issue/11', + labelIds: ['gid://gitlab/GroupLabel/5'], + assigneeIds: [], + milestoneId: undefined, + }; + + expect(result).toEqual(expected); + }); }); describe('filterVariables', () => { diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js index 85ba703a6ee..731578e15a3 100644 --- a/spec/frontend/boards/components/board_filtered_search_spec.js +++ b/spec/frontend/boards/components/board_filtered_search_spec.js @@ -124,7 +124,7 @@ describe('BoardFilteredSearch', () => { { type: 'milestone', value: { data: 'New Milestone', operator: '=' } }, { type: 'type', value: { data: 'INCIDENT', operator: '=' } }, { type: 'weight', value: { data: '2', operator: '=' } }, - { type: 'iteration', value: { data: '3341', operator: '=' } }, + { type: 'iteration', value: { data: 'Any&3', operator: '=' } }, { type: 'release', value: { data: 'v1.0.0', operator: '=' } }, ]; jest.spyOn(urlUtility, 'updateHistory'); @@ -134,7 +134,7 @@ describe('BoardFilteredSearch', () => { title: '', replace: true, url: - 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label%262&assignee_username=root&milestone_title=New%20Milestone&iteration_id=3341&types=INCIDENT&weight=2&release_tag=v1.0.0', + 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label%262&assignee_username=root&milestone_title=New%20Milestone&iteration_id=Any&iteration_cadence_id=3&types=INCIDENT&weight=2&release_tag=v1.0.0', }); }); diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index c976ba7525b..6a659623b53 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -62,7 +62,7 @@ describe('BoardForm', () => { }; }, provide: { - rootPath: 'root', + boardBaseUrl: 'root', }, mocks: { $apollo: { diff --git a/spec/frontend/boards/components/board_top_bar_spec.js b/spec/frontend/boards/components/board_top_bar_spec.js new file mode 100644 index 00000000000..997768a0cc7 --- /dev/null +++ b/spec/frontend/boards/components/board_top_bar_spec.js @@ -0,0 +1,88 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; + +import BoardTopBar from '~/boards/components/board_top_bar.vue'; +import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue'; +import BoardsSelector from '~/boards/components/boards_selector.vue'; +import ConfigToggle from '~/boards/components/config_toggle.vue'; +import IssueBoardFilteredSearch from '~/boards/components/issue_board_filtered_search.vue'; +import NewBoardButton from '~/boards/components/new_board_button.vue'; +import ToggleFocus from '~/boards/components/toggle_focus.vue'; + +describe('BoardTopBar', () => { + let wrapper; + + Vue.use(Vuex); + + const createStore = ({ mockGetters = {} } = {}) => { + return new Vuex.Store({ + state: {}, + getters: { + isEpicBoard: () => false, + ...mockGetters, + }, + }); + }; + + const createComponent = ({ provide = {}, mockGetters = {} } = {}) => { + const store = createStore({ mockGetters }); + wrapper = shallowMount(BoardTopBar, { + store, + provide: { + swimlanesFeatureAvailable: false, + canAdminList: false, + isSignedIn: false, + fullPath: 'gitlab-org', + boardType: 'group', + releasesFetchPath: '/releases', + ...provide, + }, + stubs: { IssueBoardFilteredSearch }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('base template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders BoardsSelector component', () => { + expect(wrapper.findComponent(BoardsSelector).exists()).toBe(true); + }); + + it('renders IssueBoardFilteredSearch component', () => { + expect(wrapper.findComponent(IssueBoardFilteredSearch).exists()).toBe(true); + }); + + it('renders NewBoardButton component', () => { + expect(wrapper.findComponent(NewBoardButton).exists()).toBe(true); + }); + + it('renders ConfigToggle component', () => { + expect(wrapper.findComponent(ConfigToggle).exists()).toBe(true); + }); + + it('renders ToggleFocus component', () => { + expect(wrapper.findComponent(ToggleFocus).exists()).toBe(true); + }); + + it('does not render BoardAddNewColumnTrigger component', () => { + expect(wrapper.findComponent(BoardAddNewColumnTrigger).exists()).toBe(false); + }); + }); + + describe('when user can admin list', () => { + beforeEach(() => { + createComponent({ provide: { canAdminList: true } }); + }); + + it('renders BoardAddNewColumnTrigger component', () => { + expect(wrapper.findComponent(BoardAddNewColumnTrigger).exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js index 0c044deb78c..f60d04af4fc 100644 --- a/spec/frontend/boards/components/boards_selector_spec.js +++ b/spec/frontend/boards/components/boards_selector_spec.js @@ -1,5 +1,4 @@ import { GlDropdown, GlLoadingIcon, GlDropdownSectionHeader } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import Vuex from 'vuex'; @@ -14,6 +13,7 @@ import groupRecentBoardsQuery from '~/boards/graphql/group_recent_boards.query.g import projectRecentBoardsQuery from '~/boards/graphql/project_recent_boards.query.graphql'; import defaultStore from '~/boards/stores'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { mockGroupBoardResponse, mockProjectBoardResponse, @@ -60,7 +60,7 @@ describe('BoardsSelector', () => { searchBoxInput.trigger('input'); }; - const getDropdownItems = () => wrapper.findAll('.js-dropdown-item'); + const getDropdownItems = () => wrapper.findAllByTestId('dropdown-item'); const getDropdownHeaders = () => wrapper.findAllComponents(GlDropdownSectionHeader); const getLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findDropdown = () => wrapper.findComponent(GlDropdown); @@ -100,11 +100,15 @@ describe('BoardsSelector', () => { [groupRecentBoardsQuery, groupRecentBoardsQueryHandlerSuccess], ]); - wrapper = mount(BoardsSelector, { + wrapper = mountExtended(BoardsSelector, { store, apolloProvider: fakeApollo, propsData: { throttleDuration, + }, + attachTo: document.body, + provide: { + fullPath: '', boardBaseUrl: `${TEST_HOST}/board/base/url`, hasMissingBoards: false, canAdminBoard: true, @@ -112,10 +116,6 @@ describe('BoardsSelector', () => { scopedIssueBoardFeatureEnabled: true, weights: [], }, - attachTo: document.body, - provide: { - fullPath: '', - }, }); }; diff --git a/spec/frontend/boards/components/issuable_title_spec.js b/spec/frontend/boards/components/issuable_title_spec.js deleted file mode 100644 index 4b7f491b998..00000000000 --- a/spec/frontend/boards/components/issuable_title_spec.js +++ /dev/null @@ -1,33 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import IssuableTitle from '~/boards/components/issuable_title.vue'; - -describe('IssuableTitle', () => { - let wrapper; - const defaultProps = { - title: 'One', - refPath: 'path', - }; - const createComponent = () => { - wrapper = shallowMount(IssuableTitle, { - propsData: { ...defaultProps }, - }); - }; - const findIssueContent = () => wrapper.find('[data-testid="issue-title"]'); - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('renders a title of an issue in the sidebar', () => { - expect(findIssueContent().text()).toContain('One'); - }); - - it('renders a referencePath of an issue in the sidebar', () => { - expect(findIssueContent().text()).toContain('path'); - }); -}); diff --git a/spec/frontend/boards/components/issue_board_filtered_search_spec.js b/spec/frontend/boards/components/issue_board_filtered_search_spec.js index 76e8b84d8ef..e4a6a2b8b76 100644 --- a/spec/frontend/boards/components/issue_board_filtered_search_spec.js +++ b/spec/frontend/boards/components/issue_board_filtered_search_spec.js @@ -14,10 +14,11 @@ describe('IssueBoardFilter', () => { const createComponent = ({ isSignedIn = false } = {}) => { wrapper = shallowMount(IssueBoardFilteredSpec, { - propsData: { fullPath: 'gitlab-org', boardType: 'group' }, provide: { isSignedIn, releasesFetchPath: '/releases', + fullPath: 'gitlab-org', + boardType: 'group', }, }); }; diff --git a/spec/frontend/boards/components/issue_time_estimate_spec.js b/spec/frontend/boards/components/issue_time_estimate_spec.js index 635964b6b4a..948a7a20f7f 100644 --- a/spec/frontend/boards/components/issue_time_estimate_spec.js +++ b/spec/frontend/boards/components/issue_time_estimate_spec.js @@ -5,6 +5,8 @@ import IssueTimeEstimate from '~/boards/components/issue_time_estimate.vue'; describe('Issue Time Estimate component', () => { let wrapper; + const findIssueTimeEstimate = () => wrapper.find('[data-testid="issue-time-estimate"]'); + afterEach(() => { wrapper.destroy(); }); @@ -26,7 +28,7 @@ describe('Issue Time Estimate component', () => { }); it('renders expanded time estimate in tooltip', () => { - expect(wrapper.find('.js-issue-time-estimate').text()).toContain('2 weeks 3 days 1 minute'); + expect(findIssueTimeEstimate().text()).toContain('2 weeks 3 days 1 minute'); }); it('prevents tooltip xss', async () => { @@ -42,7 +44,7 @@ describe('Issue Time Estimate component', () => { expect(alertSpy).not.toHaveBeenCalled(); expect(wrapper.find('time').text().trim()).toEqual('0m'); - expect(wrapper.find('.js-issue-time-estimate').text()).toContain('0m'); + expect(findIssueTimeEstimate().text()).toContain('0m'); }); }); @@ -63,7 +65,7 @@ describe('Issue Time Estimate component', () => { }); it('renders expanded time estimate in tooltip', () => { - expect(wrapper.find('.js-issue-time-estimate').text()).toContain('104 hours 1 minute'); + expect(findIssueTimeEstimate().text()).toContain('104 hours 1 minute'); }); }); }); diff --git a/spec/frontend/boards/components/item_count_spec.js b/spec/frontend/boards/components/item_count_spec.js index 45980c36f1c..06cd3910fc0 100644 --- a/spec/frontend/boards/components/item_count_spec.js +++ b/spec/frontend/boards/components/item_count_spec.js @@ -29,7 +29,7 @@ describe('IssueCount', () => { }); it('does not contains maxIssueCount in the template', () => { - expect(vm.find('.js-max-issue-size').exists()).toBe(false); + expect(vm.find('.max-issue-size').exists()).toBe(false); }); }); @@ -50,7 +50,7 @@ describe('IssueCount', () => { }); it('contains maxIssueCount in the template', () => { - expect(vm.find('.js-max-issue-size').text()).toEqual(String(maxIssueCount)); + expect(vm.find('.max-issue-size').text()).toEqual(String(maxIssueCount)); }); it('does not have text-danger class when issueSize is less than maxIssueCount', () => { @@ -75,7 +75,7 @@ describe('IssueCount', () => { }); it('contains maxIssueCount in the template', () => { - expect(vm.find('.js-max-issue-size').text()).toEqual(String(maxIssueCount)); + expect(vm.find('.max-issue-size').text()).toEqual(String(maxIssueCount)); }); it('has text-danger class', () => { diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index ad661a31556..eacf9db191e 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -166,31 +166,29 @@ describe('setFilters', () => { }); describe('performSearch', () => { - it('should dispatch setFilters, fetchLists and resetIssues action', (done) => { - testAction( + it('should dispatch setFilters, fetchLists and resetIssues action', () => { + return testAction( actions.performSearch, {}, {}, [], [{ type: 'setFilters', payload: {} }, { type: 'fetchLists' }, { type: 'resetIssues' }], - done, ); }); }); describe('setActiveId', () => { - it('should commit mutation SET_ACTIVE_ID', (done) => { + it('should commit mutation SET_ACTIVE_ID', () => { const state = { activeId: inactiveId, }; - testAction( + return testAction( actions.setActiveId, { id: 1, sidebarType: 'something' }, state, [{ type: types.SET_ACTIVE_ID, payload: { id: 1, sidebarType: 'something' } }], [], - done, ); }); }); @@ -219,10 +217,10 @@ describe('fetchLists', () => { const formattedLists = formatBoardLists(queryResponse.data.group.board.lists); - it('should commit mutations RECEIVE_BOARD_LISTS_SUCCESS on success', (done) => { + it('should commit mutations RECEIVE_BOARD_LISTS_SUCCESS on success', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); - testAction( + return testAction( actions.fetchLists, {}, state, @@ -233,14 +231,13 @@ describe('fetchLists', () => { }, ], [], - done, ); }); - it('should commit mutations RECEIVE_BOARD_LISTS_FAILURE on failure', (done) => { + it('should commit mutations RECEIVE_BOARD_LISTS_FAILURE on failure', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject()); - testAction( + return testAction( actions.fetchLists, {}, state, @@ -250,11 +247,10 @@ describe('fetchLists', () => { }, ], [], - done, ); }); - it('dispatch createList action when backlog list does not exist and is not hidden', (done) => { + it('dispatch createList action when backlog list does not exist and is not hidden', () => { queryResponse = { data: { group: { @@ -269,7 +265,7 @@ describe('fetchLists', () => { }; jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); - testAction( + return testAction( actions.fetchLists, {}, state, @@ -280,7 +276,6 @@ describe('fetchLists', () => { }, ], [{ type: 'createList', payload: { backlog: true } }], - done, ); }); @@ -951,10 +946,10 @@ describe('fetchItemsForList', () => { }); }); - it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_SUCCESS on success', (done) => { + it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_SUCCESS on success', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); - testAction( + return testAction( actions.fetchItemsForList, { listId }, state, @@ -973,14 +968,13 @@ describe('fetchItemsForList', () => { }, ], [], - done, ); }); - it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_FAILURE on failure', (done) => { + it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_FAILURE on failure', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject()); - testAction( + return testAction( actions.fetchItemsForList, { listId }, state, @@ -996,7 +990,6 @@ describe('fetchItemsForList', () => { { type: types.RECEIVE_ITEMS_FOR_LIST_FAILURE, payload: listId }, ], [], - done, ); }); }); @@ -1398,8 +1391,8 @@ describe('setAssignees', () => { const node = { username: 'name' }; describe('when succeeds', () => { - it('calls the correct mutation with the correct values', (done) => { - testAction( + it('calls the correct mutation with the correct values', () => { + return testAction( actions.setAssignees, { assignees: [node], iid: '1' }, { commit: () => {} }, @@ -1410,7 +1403,6 @@ describe('setAssignees', () => { }, ], [], - done, ); }); }); @@ -1728,7 +1720,7 @@ describe('setActiveItemSubscribed', () => { projectPath: 'gitlab-org/gitlab-test', }; - it('should commit subscribed status', (done) => { + it('should commit subscribed status', () => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { updateIssuableSubscription: { @@ -1746,7 +1738,7 @@ describe('setActiveItemSubscribed', () => { value: subscribedState, }; - testAction( + return testAction( actions.setActiveItemSubscribed, input, { ...state, ...getters }, @@ -1757,7 +1749,6 @@ describe('setActiveItemSubscribed', () => { }, ], [], - done, ); }); @@ -1783,7 +1774,7 @@ describe('setActiveItemTitle', () => { projectPath: 'h/b', }; - it('should commit title after setting the issue', (done) => { + it('should commit title after setting the issue', () => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { updateIssuableTitle: { @@ -1801,7 +1792,7 @@ describe('setActiveItemTitle', () => { value: testTitle, }; - testAction( + return testAction( actions.setActiveItemTitle, input, { ...state, ...getters }, @@ -1812,7 +1803,6 @@ describe('setActiveItemTitle', () => { }, ], [], - done, ); }); @@ -1829,14 +1819,14 @@ describe('setActiveItemConfidential', () => { const state = { boardItems: { [mockIssue.id]: mockIssue } }; const getters = { activeBoardItem: mockIssue }; - it('set confidential value on board item', (done) => { + it('set confidential value on board item', () => { const payload = { itemId: getters.activeBoardItem.id, prop: 'confidential', value: true, }; - testAction( + return testAction( actions.setActiveItemConfidential, true, { ...state, ...getters }, @@ -1847,7 +1837,6 @@ describe('setActiveItemConfidential', () => { }, ], [], - done, ); }); }); @@ -1876,10 +1865,10 @@ describe('fetchGroupProjects', () => { }, }; - it('should commit mutations REQUEST_GROUP_PROJECTS and RECEIVE_GROUP_PROJECTS_SUCCESS on success', (done) => { + it('should commit mutations REQUEST_GROUP_PROJECTS and RECEIVE_GROUP_PROJECTS_SUCCESS on success', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); - testAction( + return testAction( actions.fetchGroupProjects, {}, state, @@ -1894,14 +1883,13 @@ describe('fetchGroupProjects', () => { }, ], [], - done, ); }); - it('should commit mutations REQUEST_GROUP_PROJECTS and RECEIVE_GROUP_PROJECTS_FAILURE on failure', (done) => { + it('should commit mutations REQUEST_GROUP_PROJECTS and RECEIVE_GROUP_PROJECTS_FAILURE on failure', () => { jest.spyOn(gqlClient, 'query').mockRejectedValue(); - testAction( + return testAction( actions.fetchGroupProjects, {}, state, @@ -1915,16 +1903,15 @@ describe('fetchGroupProjects', () => { }, ], [], - done, ); }); }); describe('setSelectedProject', () => { - it('should commit mutation SET_SELECTED_PROJECT', (done) => { + it('should commit mutation SET_SELECTED_PROJECT', () => { const project = mockGroupProjects[0]; - testAction( + return testAction( actions.setSelectedProject, project, {}, @@ -1935,7 +1922,6 @@ describe('setSelectedProject', () => { }, ], [], - done, ); }); }); diff --git a/spec/frontend/captcha/apollo_captcha_link_spec.js b/spec/frontend/captcha/apollo_captcha_link_spec.js index eab52344d1f..cd32e63d00c 100644 --- a/spec/frontend/captcha/apollo_captcha_link_spec.js +++ b/spec/frontend/captcha/apollo_captcha_link_spec.js @@ -95,70 +95,82 @@ describe('apolloCaptchaLink', () => { return { operationName: 'operation', variables: {}, setContext: mockContext }; } - it('successful responses are passed through', (done) => { + it('successful responses are passed through', () => { setupLink(SUCCESS_RESPONSE); - link.request(mockOperation()).subscribe((result) => { - expect(result).toEqual(SUCCESS_RESPONSE); - expect(mockLinkImplementation).toHaveBeenCalledTimes(1); - expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); - done(); + + return new Promise((resolve) => { + link.request(mockOperation()).subscribe((result) => { + expect(result).toEqual(SUCCESS_RESPONSE); + expect(mockLinkImplementation).toHaveBeenCalledTimes(1); + expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); + resolve(); + }); }); }); - it('non-spam related errors are passed through', (done) => { + it('non-spam related errors are passed through', () => { setupLink(NON_CAPTCHA_ERROR_RESPONSE); - link.request(mockOperation()).subscribe((result) => { - expect(result).toEqual(NON_CAPTCHA_ERROR_RESPONSE); - expect(mockLinkImplementation).toHaveBeenCalledTimes(1); - expect(mockContext).not.toHaveBeenCalled(); - expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); - done(); + + return new Promise((resolve) => { + link.request(mockOperation()).subscribe((result) => { + expect(result).toEqual(NON_CAPTCHA_ERROR_RESPONSE); + expect(mockLinkImplementation).toHaveBeenCalledTimes(1); + expect(mockContext).not.toHaveBeenCalled(); + expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); + resolve(); + }); }); }); - it('unresolvable spam errors are passed through', (done) => { + it('unresolvable spam errors are passed through', () => { setupLink(SPAM_ERROR_RESPONSE); - link.request(mockOperation()).subscribe((result) => { - expect(result).toEqual(SPAM_ERROR_RESPONSE); - expect(mockLinkImplementation).toHaveBeenCalledTimes(1); - expect(mockContext).not.toHaveBeenCalled(); - expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); - done(); + return new Promise((resolve) => { + link.request(mockOperation()).subscribe((result) => { + expect(result).toEqual(SPAM_ERROR_RESPONSE); + expect(mockLinkImplementation).toHaveBeenCalledTimes(1); + expect(mockContext).not.toHaveBeenCalled(); + expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); + resolve(); + }); }); }); describe('resolvable spam errors', () => { - it('re-submits request with spam headers if the captcha modal was solved correctly', (done) => { + it('re-submits request with spam headers if the captcha modal was solved correctly', () => { waitForCaptchaToBeSolved.mockResolvedValue(CAPTCHA_RESPONSE); setupLink(CAPTCHA_ERROR_RESPONSE, SUCCESS_RESPONSE); - link.request(mockOperation()).subscribe((result) => { - expect(result).toEqual(SUCCESS_RESPONSE); - expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY); - expect(mockContext).toHaveBeenCalledWith({ - headers: { - 'X-GitLab-Captcha-Response': CAPTCHA_RESPONSE, - 'X-GitLab-Spam-Log-Id': SPAM_LOG_ID, - }, + return new Promise((resolve) => { + link.request(mockOperation()).subscribe((result) => { + expect(result).toEqual(SUCCESS_RESPONSE); + expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY); + expect(mockContext).toHaveBeenCalledWith({ + headers: { + 'X-GitLab-Captcha-Response': CAPTCHA_RESPONSE, + 'X-GitLab-Spam-Log-Id': SPAM_LOG_ID, + }, + }); + expect(mockLinkImplementation).toHaveBeenCalledTimes(2); + resolve(); }); - expect(mockLinkImplementation).toHaveBeenCalledTimes(2); - done(); }); }); - it('throws error if the captcha modal was not solved correctly', (done) => { + it('throws error if the captcha modal was not solved correctly', () => { const error = new UnsolvedCaptchaError(); waitForCaptchaToBeSolved.mockRejectedValue(error); setupLink(CAPTCHA_ERROR_RESPONSE, SUCCESS_RESPONSE); - link.request(mockOperation()).subscribe({ - next: done.catch, - error: (result) => { - expect(result).toEqual(error); - expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY); - expect(mockContext).not.toHaveBeenCalled(); - expect(mockLinkImplementation).toHaveBeenCalledTimes(1); - done(); - }, + return new Promise((resolve, reject) => { + link.request(mockOperation()).subscribe({ + next: reject, + error: (result) => { + expect(result).toEqual(error); + expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY); + expect(mockContext).not.toHaveBeenCalled(); + expect(mockLinkImplementation).toHaveBeenCalledTimes(1); + resolve(); + }, + }); }); }); }); diff --git a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js index 085ab1c0c30..2fedbbecd64 100644 --- a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js @@ -36,7 +36,7 @@ describe('Ci variable modal', () => { const findAddorUpdateButton = () => findModal() .findAll(GlButton) - .wrappers.find((button) => button.props('variant') === 'success'); + .wrappers.find((button) => button.props('variant') === 'confirm'); const deleteVariableButton = () => findModal() .findAll(GlButton) diff --git a/spec/frontend/ci_variable_list/store/actions_spec.js b/spec/frontend/ci_variable_list/store/actions_spec.js index 426e6cae8fb..eb31fcd3ef4 100644 --- a/spec/frontend/ci_variable_list/store/actions_spec.js +++ b/spec/frontend/ci_variable_list/store/actions_spec.js @@ -86,10 +86,10 @@ describe('CI variable list store actions', () => { }); describe('deleteVariable', () => { - it('dispatch correct actions on successful deleted variable', (done) => { + it('dispatch correct actions on successful deleted variable', () => { mock.onPatch(state.endpoint).reply(200); - testAction( + return testAction( actions.deleteVariable, {}, state, @@ -99,16 +99,13 @@ describe('CI variable list store actions', () => { { type: 'receiveDeleteVariableSuccess' }, { type: 'fetchVariables' }, ], - () => { - done(); - }, ); }); - it('should show flash error and set error in state on delete failure', (done) => { + it('should show flash error and set error in state on delete failure', async () => { mock.onPatch(state.endpoint).reply(500, ''); - testAction( + await testAction( actions.deleteVariable, {}, state, @@ -120,19 +117,16 @@ describe('CI variable list store actions', () => { payload: payloadError, }, ], - () => { - expect(createFlash).toHaveBeenCalled(); - done(); - }, ); + expect(createFlash).toHaveBeenCalled(); }); }); describe('updateVariable', () => { - it('dispatch correct actions on successful updated variable', (done) => { + it('dispatch correct actions on successful updated variable', () => { mock.onPatch(state.endpoint).reply(200); - testAction( + return testAction( actions.updateVariable, {}, state, @@ -142,16 +136,13 @@ describe('CI variable list store actions', () => { { type: 'receiveUpdateVariableSuccess' }, { type: 'fetchVariables' }, ], - () => { - done(); - }, ); }); - it('should show flash error and set error in state on update failure', (done) => { + it('should show flash error and set error in state on update failure', async () => { mock.onPatch(state.endpoint).reply(500, ''); - testAction( + await testAction( actions.updateVariable, mockVariable, state, @@ -163,19 +154,16 @@ describe('CI variable list store actions', () => { payload: payloadError, }, ], - () => { - expect(createFlash).toHaveBeenCalled(); - done(); - }, ); + expect(createFlash).toHaveBeenCalled(); }); }); describe('addVariable', () => { - it('dispatch correct actions on successful added variable', (done) => { + it('dispatch correct actions on successful added variable', () => { mock.onPatch(state.endpoint).reply(200); - testAction( + return testAction( actions.addVariable, {}, state, @@ -185,16 +173,13 @@ describe('CI variable list store actions', () => { { type: 'receiveAddVariableSuccess' }, { type: 'fetchVariables' }, ], - () => { - done(); - }, ); }); - it('should show flash error and set error in state on add failure', (done) => { + it('should show flash error and set error in state on add failure', async () => { mock.onPatch(state.endpoint).reply(500, ''); - testAction( + await testAction( actions.addVariable, {}, state, @@ -206,19 +191,16 @@ describe('CI variable list store actions', () => { payload: payloadError, }, ], - () => { - expect(createFlash).toHaveBeenCalled(); - done(); - }, ); + expect(createFlash).toHaveBeenCalled(); }); }); describe('fetchVariables', () => { - it('dispatch correct actions on fetchVariables', (done) => { + it('dispatch correct actions on fetchVariables', () => { mock.onGet(state.endpoint).reply(200, { variables: mockData.mockVariables }); - testAction( + return testAction( actions.fetchVariables, {}, state, @@ -230,29 +212,24 @@ describe('CI variable list store actions', () => { payload: prepareDataForDisplay(mockData.mockVariables), }, ], - () => { - done(); - }, ); }); - it('should show flash error and set error in state on fetch variables failure', (done) => { + it('should show flash error and set error in state on fetch variables failure', async () => { mock.onGet(state.endpoint).reply(500); - testAction(actions.fetchVariables, {}, state, [], [{ type: 'requestVariables' }], () => { - expect(createFlash).toHaveBeenCalledWith({ - message: 'There was an error fetching the variables.', - }); - done(); + await testAction(actions.fetchVariables, {}, state, [], [{ type: 'requestVariables' }]); + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was an error fetching the variables.', }); }); }); describe('fetchEnvironments', () => { - it('dispatch correct actions on fetchEnvironments', (done) => { + it('dispatch correct actions on fetchEnvironments', () => { Api.environments = jest.fn().mockResolvedValue({ data: mockData.mockEnvironments }); - testAction( + return testAction( actions.fetchEnvironments, {}, state, @@ -264,28 +241,17 @@ describe('CI variable list store actions', () => { payload: prepareEnvironments(mockData.mockEnvironments), }, ], - () => { - done(); - }, ); }); - it('should show flash error and set error in state on fetch environments failure', (done) => { + it('should show flash error and set error in state on fetch environments failure', async () => { Api.environments = jest.fn().mockRejectedValue(); - testAction( - actions.fetchEnvironments, - {}, - state, - [], - [{ type: 'requestEnvironments' }], - () => { - expect(createFlash).toHaveBeenCalledWith({ - message: 'There was an error fetching the environments information.', - }); - done(); - }, - ); + await testAction(actions.fetchEnvironments, {}, state, [], [{ type: 'requestEnvironments' }]); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was an error fetching the environments information.', + }); }); }); diff --git a/spec/frontend/clusters_list/components/agent_empty_state_spec.js b/spec/frontend/clusters_list/components/agent_empty_state_spec.js index ed2a0d0b97b..22775aa6603 100644 --- a/spec/frontend/clusters_list/components/agent_empty_state_spec.js +++ b/spec/frontend/clusters_list/components/agent_empty_state_spec.js @@ -1,8 +1,6 @@ -import { GlEmptyState, GlSprintf, GlLink, GlButton } from '@gitlab/ui'; +import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; import AgentEmptyState from '~/clusters_list/components/agent_empty_state.vue'; -import { INSTALL_AGENT_MODAL_ID } from '~/clusters_list/constants'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { helpPagePath } from '~/helpers/help_page_helper'; const emptyStateImage = '/path/to/image'; @@ -15,16 +13,12 @@ describe('AgentEmptyStateComponent', () => { }; const findInstallDocsLink = () => wrapper.findComponent(GlLink); - const findIntegrationButton = () => wrapper.findComponent(GlButton); const findEmptyState = () => wrapper.findComponent(GlEmptyState); beforeEach(() => { wrapper = shallowMountExtended(AgentEmptyState, { provide: provideData, - directives: { - GlModalDirective: createMockDirective(), - }, - stubs: { GlEmptyState, GlSprintf }, + stubs: { GlSprintf }, }); }); @@ -38,17 +32,7 @@ describe('AgentEmptyStateComponent', () => { expect(findEmptyState().exists()).toBe(true); }); - it('renders button for the agent registration', () => { - expect(findIntegrationButton().exists()).toBe(true); - }); - it('renders correct href attributes for the docs link', () => { expect(findInstallDocsLink().attributes('href')).toBe(installDocsUrl); }); - - it('renders correct modal id for the agent registration modal', () => { - const binding = getBinding(findIntegrationButton().element, 'gl-modal-directive'); - - expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID); - }); }); diff --git a/spec/frontend/clusters_list/components/agent_table_spec.js b/spec/frontend/clusters_list/components/agent_table_spec.js index db723622a51..a466a35428a 100644 --- a/spec/frontend/clusters_list/components/agent_table_spec.js +++ b/spec/frontend/clusters_list/components/agent_table_spec.js @@ -9,7 +9,7 @@ import timeagoMixin from '~/vue_shared/mixins/timeago'; import { clusterAgents, connectedTimeNow, connectedTimeInactive } from './mock_data'; const defaultConfigHelpUrl = - '/help/user/clusters/agent/install/index#create-an-agent-without-configuration-file'; + '/help/user/clusters/agent/install/index#create-an-agent-configuration-file'; const provideData = { gitlabVersion: '14.8', diff --git a/spec/frontend/clusters_list/components/agent_token_spec.js b/spec/frontend/clusters_list/components/agent_token_spec.js index a80c8ffaad4..7f6ec2eb3a2 100644 --- a/spec/frontend/clusters_list/components/agent_token_spec.js +++ b/spec/frontend/clusters_list/components/agent_token_spec.js @@ -53,7 +53,7 @@ describe('InstallAgentModal', () => { }); it('shows agent token as an input value', () => { - expect(findInput().props('value')).toBe('agent-token'); + expect(findInput().props('value')).toBe(agentToken); }); it('renders a copy button', () => { @@ -65,12 +65,12 @@ describe('InstallAgentModal', () => { }); it('shows warning alert', () => { - expect(findAlert().props('title')).toBe(I18N_AGENT_TOKEN.tokenSingleUseWarningTitle); + expect(findAlert().text()).toBe(I18N_AGENT_TOKEN.tokenSingleUseWarningTitle); }); it('shows code block with agent installation command', () => { - expect(findCodeBlock().props('code')).toContain('--agent-token=agent-token'); - expect(findCodeBlock().props('code')).toContain('--kas-address=kas.example.com'); + expect(findCodeBlock().props('code')).toContain(`--set config.token=${agentToken}`); + expect(findCodeBlock().props('code')).toContain(`--set config.kasAddress=${kasAddress}`); }); }); }); diff --git a/spec/frontend/clusters_list/components/agents_spec.js b/spec/frontend/clusters_list/components/agents_spec.js index 3cfa4b92bc0..92cfff7d490 100644 --- a/spec/frontend/clusters_list/components/agents_spec.js +++ b/spec/frontend/clusters_list/components/agents_spec.js @@ -308,7 +308,7 @@ describe('Agents', () => { }); it('displays an alert message', () => { - expect(findAlert().text()).toBe('An error occurred while loading your Agents'); + expect(findAlert().text()).toBe('An error occurred while loading your agents'); }); }); diff --git a/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js index eca2b1f5cb1..197735d3c77 100644 --- a/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js +++ b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js @@ -1,5 +1,6 @@ import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { ENTER_KEY } from '~/lib/utils/keys'; import AvailableAgentsDropdown from '~/clusters_list/components/available_agents_dropdown.vue'; import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '~/clusters_list/constants'; @@ -18,6 +19,7 @@ describe('AvailableAgentsDropdown', () => { propsData, stubs: { GlDropdown }, }); + wrapper.vm.$refs.dropdown.hide = jest.fn(); }; afterEach(() => { @@ -96,6 +98,25 @@ describe('AvailableAgentsDropdown', () => { expect(findDropdown().props('text')).toBe('new-agent'); }); }); + + describe('click enter to register new agent without configuration', () => { + beforeEach(async () => { + await findSearchInput().vm.$emit('input', 'new-agent'); + await findSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); + }); + + it('emits agentSelected with the name of the clicked agent', () => { + expect(wrapper.emitted('agentSelected')).toEqual([['new-agent']]); + }); + + it('marks the clicked item as selected', () => { + expect(findDropdown().props('text')).toBe('new-agent'); + }); + + it('closes the dropdown', () => { + expect(wrapper.vm.$refs.dropdown.hide).toHaveBeenCalledTimes(1); + }); + }); }); describe('registration in progress', () => { diff --git a/spec/frontend/clusters_list/components/clusters_actions_spec.js b/spec/frontend/clusters_list/components/clusters_actions_spec.js index 312df12ab5f..21dcc66c639 100644 --- a/spec/frontend/clusters_list/components/clusters_actions_spec.js +++ b/spec/frontend/clusters_list/components/clusters_actions_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem, GlButton } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlTooltip } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ClustersActions from '~/clusters_list/components/clusters_actions.vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; @@ -7,12 +7,14 @@ import { INSTALL_AGENT_MODAL_ID, CLUSTERS_ACTIONS } from '~/clusters_list/consta describe('ClustersActionsComponent', () => { let wrapper; - const newClusterPath = 'path/to/create/cluster'; + const newClusterPath = 'path/to/add/cluster'; const addClusterPath = 'path/to/connect/existing/cluster'; + const newClusterDocsPath = 'path/to/create/new/cluster'; const defaultProvide = { newClusterPath, addClusterPath, + newClusterDocsPath, canAddCluster: true, displayClusterAgents: true, certificateBasedClustersEnabled: true, @@ -20,12 +22,13 @@ describe('ClustersActionsComponent', () => { const findDropdown = () => wrapper.findComponent(GlDropdown); const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findTooltip = () => wrapper.findComponent(GlTooltip); const findDropdownItemIds = () => findDropdownItems().wrappers.map((x) => x.attributes('data-testid')); + const findDropdownItemTexts = () => findDropdownItems().wrappers.map((x) => x.text()); const findNewClusterLink = () => wrapper.findByTestId('new-cluster-link'); + const findNewClusterDocsLink = () => wrapper.findByTestId('create-cluster-link'); const findConnectClusterLink = () => wrapper.findByTestId('connect-cluster-link'); - const findConnectNewAgentLink = () => wrapper.findByTestId('connect-new-agent-link'); - const findConnectWithAgentButton = () => wrapper.findComponent(GlButton); const createWrapper = (provideData = {}) => { wrapper = shallowMountExtended(ClustersActions, { @@ -35,7 +38,6 @@ describe('ClustersActionsComponent', () => { }, directives: { GlModalDirective: createMockDirective(), - GlTooltip: createMockDirective(), }, }); }; @@ -49,12 +51,15 @@ describe('ClustersActionsComponent', () => { }); describe('when the certificate based clusters are enabled', () => { it('renders actions menu', () => { - expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.actionsButton); + expect(findDropdown().exists()).toBe(true); }); - it('renders correct href attributes for the links', () => { - expect(findNewClusterLink().attributes('href')).toBe(newClusterPath); - expect(findConnectClusterLink().attributes('href')).toBe(addClusterPath); + it('shows split button in dropdown', () => { + expect(findDropdown().props('split')).toBe(true); + }); + + it("doesn't show the tooltip", () => { + expect(findTooltip().exists()).toBe(false); }); describe('when user cannot add clusters', () => { @@ -67,8 +72,7 @@ describe('ClustersActionsComponent', () => { }); it('shows tooltip explaining why dropdown is disabled', () => { - const tooltip = getBinding(findDropdown().element, 'gl-tooltip'); - expect(tooltip.value).toBe(CLUSTERS_ACTIONS.dropdownDisabledHint); + expect(findTooltip().attributes('title')).toBe(CLUSTERS_ACTIONS.dropdownDisabledHint); }); it('does not bind split dropdown button', () => { @@ -79,33 +83,36 @@ describe('ClustersActionsComponent', () => { }); describe('when on project level', () => { - it('renders a dropdown with 3 actions items', () => { - expect(findDropdownItemIds()).toEqual([ - 'connect-new-agent-link', - 'new-cluster-link', - 'connect-cluster-link', - ]); + it(`displays default action as ${CLUSTERS_ACTIONS.connectWithAgent}`, () => { + expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.connectWithAgent); }); - it('renders correct modal id for the agent link', () => { - const binding = getBinding(findConnectNewAgentLink().element, 'gl-modal-directive'); + it('renders correct modal id for the default action', () => { + const binding = getBinding(findDropdown().element, 'gl-modal-directive'); expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID); }); - it('shows tooltip', () => { - const tooltip = getBinding(findDropdown().element, 'gl-tooltip'); - expect(tooltip.value).toBe(CLUSTERS_ACTIONS.connectWithAgent); + it('renders a dropdown with 3 actions items', () => { + expect(findDropdownItemIds()).toEqual([ + 'create-cluster-link', + 'new-cluster-link', + 'connect-cluster-link', + ]); }); - it('shows split button in dropdown', () => { - expect(findDropdown().props('split')).toBe(true); + it('renders correct texts for the dropdown items', () => { + expect(findDropdownItemTexts()).toEqual([ + CLUSTERS_ACTIONS.createCluster, + CLUSTERS_ACTIONS.createClusterCertificate, + CLUSTERS_ACTIONS.connectClusterCertificate, + ]); }); - it('binds split button with modal id', () => { - const binding = getBinding(findDropdown().element, 'gl-modal-directive'); - - expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID); + it('renders correct href attributes for the links', () => { + expect(findNewClusterDocsLink().attributes('href')).toBe(newClusterDocsPath); + expect(findNewClusterLink().attributes('href')).toBe(newClusterPath); + expect(findConnectClusterLink().attributes('href')).toBe(addClusterPath); }); }); @@ -114,17 +121,20 @@ describe('ClustersActionsComponent', () => { createWrapper({ displayClusterAgents: false }); }); - it('renders a dropdown with 2 actions items', () => { - expect(findDropdownItemIds()).toEqual(['new-cluster-link', 'connect-cluster-link']); + it(`displays default action as ${CLUSTERS_ACTIONS.connectClusterDeprecated}`, () => { + expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.connectClusterDeprecated); }); - it('shows tooltip', () => { - const tooltip = getBinding(findDropdown().element, 'gl-tooltip'); - expect(tooltip.value).toBe(CLUSTERS_ACTIONS.connectExistingCluster); + it('renders a dropdown with 1 action item', () => { + expect(findDropdownItemIds()).toEqual(['new-cluster-link']); }); - it('does not show split button in dropdown', () => { - expect(findDropdown().props('split')).toBe(false); + it('renders correct text for the dropdown item', () => { + expect(findDropdownItemTexts()).toEqual([CLUSTERS_ACTIONS.createClusterDeprecated]); + }); + + it('renders correct href attributes for the links', () => { + expect(findNewClusterLink().attributes('href')).toBe(newClusterPath); }); it('does not bind dropdown button to modal', () => { @@ -140,17 +150,26 @@ describe('ClustersActionsComponent', () => { createWrapper({ certificateBasedClustersEnabled: false }); }); - it('it does not show the the dropdown', () => { - expect(findDropdown().exists()).toBe(false); + it(`displays default action as ${CLUSTERS_ACTIONS.connectCluster}`, () => { + expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.connectCluster); }); - it('shows the connect with agent button', () => { - expect(findConnectWithAgentButton().props()).toMatchObject({ - disabled: !defaultProvide.canAddCluster, - category: 'primary', - variant: 'confirm', - }); - expect(findConnectWithAgentButton().text()).toBe(CLUSTERS_ACTIONS.connectWithAgent); + it('renders correct modal id for the default action', () => { + const binding = getBinding(findDropdown().element, 'gl-modal-directive'); + + expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID); + }); + + it('renders a dropdown with 1 action item', () => { + expect(findDropdownItemIds()).toEqual(['create-cluster-link']); + }); + + it('renders correct text for the dropdown item', () => { + expect(findDropdownItemTexts()).toEqual([CLUSTERS_ACTIONS.createCluster]); + }); + + it('renders correct href attributes for the links', () => { + expect(findNewClusterDocsLink().attributes('href')).toBe(newClusterDocsPath); }); }); }); diff --git a/spec/frontend/clusters_list/components/clusters_empty_state_spec.js b/spec/frontend/clusters_list/components/clusters_empty_state_spec.js index fe2189296a6..2c3a224f3c8 100644 --- a/spec/frontend/clusters_list/components/clusters_empty_state_spec.js +++ b/spec/frontend/clusters_list/components/clusters_empty_state_spec.js @@ -1,10 +1,8 @@ -import { GlEmptyState, GlButton } from '@gitlab/ui'; +import { GlEmptyState } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ClustersEmptyState from '~/clusters_list/components/clusters_empty_state.vue'; -import ClusterStore from '~/clusters_list/store'; const clustersEmptyStateImage = 'path/to/svg'; -const addClusterPath = '/path/to/connect/cluster'; const emptyStateHelpText = 'empty state text'; describe('ClustersEmptyStateComponent', () => { @@ -12,52 +10,28 @@ describe('ClustersEmptyStateComponent', () => { const defaultProvideData = { clustersEmptyStateImage, - addClusterPath, }; - const findButton = () => wrapper.findComponent(GlButton); const findEmptyStateText = () => wrapper.findByTestId('clusters-empty-state-text'); - const createWrapper = ({ - provideData = { emptyStateHelpText: null }, - isChildComponent = false, - canAddCluster = true, - } = {}) => { + const createWrapper = ({ provideData = { emptyStateHelpText: null } } = {}) => { wrapper = shallowMountExtended(ClustersEmptyState, { - store: ClusterStore({ canAddCluster }), - propsData: { isChildComponent }, provide: { ...defaultProvideData, ...provideData }, stubs: { GlEmptyState }, }); }; - beforeEach(() => { - createWrapper(); - }); - afterEach(() => { wrapper.destroy(); }); - describe('when the component is loaded independently', () => { - it('should render the action button', () => { - expect(findButton().exists()).toBe(true); - }); - }); - describe('when the help text is not provided', () => { - it('should not render the empty state text', () => { - expect(findEmptyStateText().exists()).toBe(false); - }); - }); - - describe('when the component is loaded as a child component', () => { beforeEach(() => { - createWrapper({ isChildComponent: true }); + createWrapper(); }); - it('should not render the action button', () => { - expect(findButton().exists()).toBe(false); + it('should not render the empty state text', () => { + expect(findEmptyStateText().exists()).toBe(false); }); }); @@ -70,13 +44,4 @@ describe('ClustersEmptyStateComponent', () => { expect(findEmptyStateText().text()).toBe(emptyStateHelpText); }); }); - - describe('when the user cannot add clusters', () => { - beforeEach(() => { - createWrapper({ canAddCluster: false }); - }); - it('should disable the button', () => { - expect(findButton().props('disabled')).toBe(true); - }); - }); }); diff --git a/spec/frontend/clusters_list/components/clusters_view_all_spec.js b/spec/frontend/clusters_list/components/clusters_view_all_spec.js index 2c1e3d909cc..b4eb9242003 100644 --- a/spec/frontend/clusters_list/components/clusters_view_all_spec.js +++ b/spec/frontend/clusters_list/components/clusters_view_all_spec.js @@ -1,24 +1,21 @@ -import { GlCard, GlLoadingIcon, GlButton, GlSprintf, GlBadge } from '@gitlab/ui'; +import { GlCard, GlLoadingIcon, GlSprintf, GlBadge } from '@gitlab/ui'; import Vue from 'vue'; import Vuex from 'vuex'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ClustersViewAll from '~/clusters_list/components/clusters_view_all.vue'; import Agents from '~/clusters_list/components/agents.vue'; import Clusters from '~/clusters_list/components/clusters.vue'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { AGENT, CERTIFICATE_BASED, AGENT_CARD_INFO, CERTIFICATE_BASED_CARD_INFO, MAX_CLUSTERS_LIST, - INSTALL_AGENT_MODAL_ID, } from '~/clusters_list/constants'; import { sprintf } from '~/locale'; Vue.use(Vuex); -const addClusterPath = '/path/to/add/cluster'; const defaultBranchName = 'default-branch'; describe('ClustersViewAllComponent', () => { @@ -32,11 +29,6 @@ describe('ClustersViewAllComponent', () => { defaultBranchName, }; - const defaultProvide = { - addClusterPath, - canAddCluster: true, - }; - const entryData = { loadingClusters: false, totalClusters: 0, @@ -46,37 +38,20 @@ describe('ClustersViewAllComponent', () => { const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAgentsComponent = () => wrapper.findComponent(Agents); const findClustersComponent = () => wrapper.findComponent(Clusters); - const findInstallAgentButtonTooltip = () => wrapper.findByTestId('install-agent-button-tooltip'); - const findConnectExistingClusterButtonTooltip = () => - wrapper.findByTestId('connect-existing-cluster-button-tooltip'); const findCardsContainer = () => wrapper.findByTestId('clusters-cards-container'); const findAgentCardTitle = () => wrapper.findByTestId('agent-card-title'); const findRecommendedBadge = () => wrapper.findComponent(GlBadge); const findClustersCardTitle = () => wrapper.findByTestId('clusters-card-title'); - const findFooterButton = (line) => findCards().at(line).findComponent(GlButton); - const getTooltipText = (el) => { - const binding = getBinding(el, 'gl-tooltip'); - - return binding.value; - }; const createStore = (initialState) => new Vuex.Store({ state: initialState, }); - const createWrapper = ({ initialState = entryData, provideData } = {}) => { + const createWrapper = ({ initialState = entryData } = {}) => { wrapper = shallowMountExtended(ClustersViewAll, { store: createStore(initialState), propsData, - provide: { - ...defaultProvide, - ...provideData, - }, - directives: { - GlModalDirective: createMockDirective(), - GlTooltip: createMockDirective(), - }, stubs: { GlCard, GlSprintf }, }); }; @@ -138,25 +113,10 @@ describe('ClustersViewAllComponent', () => { expect(findAgentsComponent().props('defaultBranchName')).toBe(defaultBranchName); }); - it('should show install new Agent button in the footer', () => { - expect(findFooterButton(0).exists()).toBe(true); - expect(findFooterButton(0).props('disabled')).toBe(false); - }); - - it('does not show tooltip for install new Agent button', () => { - expect(getTooltipText(findInstallAgentButtonTooltip().element)).toBe(''); - }); - describe('when there are no agents', () => { it('should show the empty title', () => { expect(findAgentCardTitle().text()).toBe(AGENT_CARD_INFO.emptyTitle); }); - - it('should render correct modal id for the agent link', () => { - const binding = getBinding(findFooterButton(0).element, 'gl-modal-directive'); - - expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID); - }); }); describe('when the agents are present', () => { @@ -191,22 +151,6 @@ describe('ClustersViewAllComponent', () => { }); }); }); - - describe('when the user cannot add clusters', () => { - beforeEach(() => { - createWrapper({ provideData: { canAddCluster: false } }); - }); - - it('should disable the button', () => { - expect(findFooterButton(0).props('disabled')).toBe(true); - }); - - it('should show a tooltip explaining why the button is disabled', () => { - expect(getTooltipText(findInstallAgentButtonTooltip().element)).toBe( - AGENT_CARD_INFO.installAgentDisabledHint, - ); - }); - }); }); describe('clusters tab', () => { @@ -214,43 +158,10 @@ describe('ClustersViewAllComponent', () => { expect(findClustersComponent().props('limit')).toBe(MAX_CLUSTERS_LIST); }); - it('should pass the is-child-component prop', () => { - expect(findClustersComponent().props('isChildComponent')).toBe(true); - }); - describe('when there are no clusters', () => { it('should show the empty title', () => { expect(findClustersCardTitle().text()).toBe(CERTIFICATE_BASED_CARD_INFO.emptyTitle); }); - - it('should show install new cluster button in the footer', () => { - expect(findFooterButton(1).exists()).toBe(true); - expect(findFooterButton(1).props('disabled')).toBe(false); - }); - - it('should render correct href for the button in the footer', () => { - expect(findFooterButton(1).attributes('href')).toBe(addClusterPath); - }); - - it('does not show tooltip for install new cluster button', () => { - expect(getTooltipText(findConnectExistingClusterButtonTooltip().element)).toBe(''); - }); - }); - - describe('when the user cannot add clusters', () => { - beforeEach(() => { - createWrapper({ provideData: { canAddCluster: false } }); - }); - - it('should disable the button', () => { - expect(findFooterButton(1).props('disabled')).toBe(true); - }); - - it('should show a tooltip explaining why the button is disabled', () => { - expect(getTooltipText(findConnectExistingClusterButtonTooltip().element)).toBe( - CERTIFICATE_BASED_CARD_INFO.connectExistingClusterDisabledHint, - ); - }); }); describe('when the clusters are present', () => { diff --git a/spec/frontend/clusters_list/mocks/apollo.js b/spec/frontend/clusters_list/mocks/apollo.js index b0f2978a230..3467b4c665c 100644 --- a/spec/frontend/clusters_list/mocks/apollo.js +++ b/spec/frontend/clusters_list/mocks/apollo.js @@ -1,4 +1,5 @@ const agent = { + __typename: 'ClusterAgent', id: 'agent-id', name: 'agent-name', webPath: 'agent-webPath', diff --git a/spec/frontend/clusters_list/store/actions_spec.js b/spec/frontend/clusters_list/store/actions_spec.js index f4b69053e14..7663f329b3f 100644 --- a/spec/frontend/clusters_list/store/actions_spec.js +++ b/spec/frontend/clusters_list/store/actions_spec.js @@ -24,14 +24,12 @@ describe('Clusters store actions', () => { captureException.mockRestore(); }); - it('should report sentry error', (done) => { + it('should report sentry error', async () => { const sentryError = new Error('New Sentry Error'); const tag = 'sentryErrorTag'; - testAction(actions.reportSentryError, { error: sentryError, tag }, {}, [], [], () => { - expect(captureException).toHaveBeenCalledWith(sentryError); - done(); - }); + await testAction(actions.reportSentryError, { error: sentryError, tag }, {}, [], []); + expect(captureException).toHaveBeenCalledWith(sentryError); }); }); @@ -62,10 +60,10 @@ describe('Clusters store actions', () => { afterEach(() => mock.restore()); - it('should commit SET_CLUSTERS_DATA with received response', (done) => { + it('should commit SET_CLUSTERS_DATA with received response', () => { mock.onGet().reply(200, apiData, headers); - testAction( + return testAction( actions.fetchClusters, { endpoint: apiData.endpoint }, {}, @@ -75,14 +73,13 @@ describe('Clusters store actions', () => { { type: types.SET_LOADING_CLUSTERS, payload: false }, ], [], - () => done(), ); }); - it('should show flash on API error', (done) => { + it('should show flash on API error', async () => { mock.onGet().reply(400, 'Not Found'); - testAction( + await testAction( actions.fetchClusters, { endpoint: apiData.endpoint }, {}, @@ -100,13 +97,10 @@ describe('Clusters store actions', () => { }, }, ], - () => { - expect(createFlash).toHaveBeenCalledWith({ - message: expect.stringMatching('error'), - }); - done(); - }, ); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringMatching('error'), + }); }); describe('multiple api requests', () => { @@ -128,8 +122,8 @@ describe('Clusters store actions', () => { pollStop.mockRestore(); }); - it('should stop polling after MAX Requests', (done) => { - testAction( + it('should stop polling after MAX Requests', async () => { + await testAction( actions.fetchClusters, { endpoint: apiData.endpoint }, {}, @@ -139,47 +133,43 @@ describe('Clusters store actions', () => { { type: types.SET_LOADING_CLUSTERS, payload: false }, ], [], - () => { - expect(pollRequest).toHaveBeenCalledTimes(1); + ); + expect(pollRequest).toHaveBeenCalledTimes(1); + expect(pollStop).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(pollInterval); + + return waitForPromises() + .then(() => { + expect(pollRequest).toHaveBeenCalledTimes(2); expect(pollStop).toHaveBeenCalledTimes(0); jest.advanceTimersByTime(pollInterval); - - waitForPromises() - .then(() => { - expect(pollRequest).toHaveBeenCalledTimes(2); - expect(pollStop).toHaveBeenCalledTimes(0); - jest.advanceTimersByTime(pollInterval); - }) - .then(() => waitForPromises()) - .then(() => { - expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS); - expect(pollStop).toHaveBeenCalledTimes(0); - jest.advanceTimersByTime(pollInterval); - }) - .then(() => waitForPromises()) - .then(() => { - expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS + 1); - // Stops poll once it exceeds the MAX_REQUESTS limit - expect(pollStop).toHaveBeenCalledTimes(1); - jest.advanceTimersByTime(pollInterval); - }) - .then(() => waitForPromises()) - .then(() => { - // Additional poll requests are not made once pollStop is called - expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS + 1); - expect(pollStop).toHaveBeenCalledTimes(1); - }) - .then(done) - .catch(done.fail); - }, - ); + }) + .then(() => waitForPromises()) + .then(() => { + expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS); + expect(pollStop).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(pollInterval); + }) + .then(() => waitForPromises()) + .then(() => { + expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS + 1); + // Stops poll once it exceeds the MAX_REQUESTS limit + expect(pollStop).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(pollInterval); + }) + .then(() => waitForPromises()) + .then(() => { + // Additional poll requests are not made once pollStop is called + expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS + 1); + expect(pollStop).toHaveBeenCalledTimes(1); + }); }); - it('should stop polling and report to Sentry when data is invalid', (done) => { + it('should stop polling and report to Sentry when data is invalid', async () => { const badApiResponse = { clusters: {} }; mock.onGet().reply(200, badApiResponse, pollHeaders); - testAction( + await testAction( actions.fetchClusters, { endpoint: apiData.endpoint }, {}, @@ -202,12 +192,9 @@ describe('Clusters store actions', () => { }, }, ], - () => { - expect(pollRequest).toHaveBeenCalledTimes(1); - expect(pollStop).toHaveBeenCalledTimes(1); - done(); - }, ); + expect(pollRequest).toHaveBeenCalledTimes(1); + expect(pollStop).toHaveBeenCalledTimes(1); }); }); }); diff --git a/spec/frontend/code_navigation/components/app_spec.js b/spec/frontend/code_navigation/components/app_spec.js index 0d7c0360e9b..f2f97092c5a 100644 --- a/spec/frontend/code_navigation/components/app_spec.js +++ b/spec/frontend/code_navigation/components/app_spec.js @@ -38,12 +38,17 @@ describe('Code navigation app component', () => { const codeNavigationPath = 'code/nav/path.js'; const path = 'blob/path.js'; const definitionPathPrefix = 'path/prefix'; + const wrapTextNodes = true; - factory({}, { codeNavigationPath, blobPath: path, pathPrefix: definitionPathPrefix }); + factory( + {}, + { codeNavigationPath, blobPath: path, pathPrefix: definitionPathPrefix, wrapTextNodes }, + ); expect(setInitialData).toHaveBeenCalledWith(expect.anything(), { blobs: [{ codeNavigationPath, path }], definitionPathPrefix, + wrapTextNodes, }); }); diff --git a/spec/frontend/code_navigation/store/actions_spec.js b/spec/frontend/code_navigation/store/actions_spec.js index 73f935deeca..c26416aca94 100644 --- a/spec/frontend/code_navigation/store/actions_spec.js +++ b/spec/frontend/code_navigation/store/actions_spec.js @@ -7,15 +7,16 @@ import axios from '~/lib/utils/axios_utils'; jest.mock('~/code_navigation/utils'); describe('Code navigation actions', () => { + const wrapTextNodes = true; + describe('setInitialData', () => { - it('commits SET_INITIAL_DATA', (done) => { - testAction( + it('commits SET_INITIAL_DATA', () => { + return testAction( actions.setInitialData, - { projectPath: 'test' }, + { projectPath: 'test', wrapTextNodes }, {}, - [{ type: 'SET_INITIAL_DATA', payload: { projectPath: 'test' } }], + [{ type: 'SET_INITIAL_DATA', payload: { projectPath: 'test', wrapTextNodes } }], [], - done, ); }); }); @@ -30,7 +31,7 @@ describe('Code navigation actions', () => { const codeNavigationPath = 'gitlab-org/gitlab-shell/-/jobs/1114/artifacts/raw/lsif/cmd/check/main.go.json'; - const state = { blobs: [{ path: 'index.js', codeNavigationPath }] }; + const state = { blobs: [{ path: 'index.js', codeNavigationPath }], wrapTextNodes }; beforeEach(() => { window.gon = { api_version: '1' }; @@ -57,8 +58,8 @@ describe('Code navigation actions', () => { ]); }); - it('commits REQUEST_DATA_SUCCESS with normalized data', (done) => { - testAction( + it('commits REQUEST_DATA_SUCCESS with normalized data', () => { + return testAction( actions.fetchData, null, state, @@ -80,12 +81,11 @@ describe('Code navigation actions', () => { }, ], [], - done, ); }); - it('calls addInteractionClass with data', (done) => { - testAction( + it('calls addInteractionClass with data', () => { + return testAction( actions.fetchData, null, state, @@ -107,16 +107,17 @@ describe('Code navigation actions', () => { }, ], [], - ) - .then(() => { - expect(addInteractionClass).toHaveBeenCalledWith('index.js', { + ).then(() => { + expect(addInteractionClass).toHaveBeenCalledWith({ + path: 'index.js', + d: { start_line: 0, start_char: 0, hover: { value: '123' }, - }); - }) - .then(done) - .catch(done.fail); + }, + wrapTextNodes, + }); + }); }); }); @@ -125,14 +126,13 @@ describe('Code navigation actions', () => { mock.onGet(codeNavigationPath).replyOnce(500); }); - it('dispatches requestDataError', (done) => { - testAction( + it('dispatches requestDataError', () => { + return testAction( actions.fetchData, null, state, [{ type: 'REQUEST_DATA' }], [{ type: 'requestDataError' }], - done, ); }); }); @@ -144,14 +144,19 @@ describe('Code navigation actions', () => { data: { 'index.js': { '0:0': 'test', '1:1': 'console.log' }, }, + wrapTextNodes, }; actions.showBlobInteractionZones({ state }, 'index.js'); expect(addInteractionClass).toHaveBeenCalled(); expect(addInteractionClass.mock.calls.length).toBe(2); - expect(addInteractionClass.mock.calls[0]).toEqual(['index.js', 'test']); - expect(addInteractionClass.mock.calls[1]).toEqual(['index.js', 'console.log']); + expect(addInteractionClass.mock.calls[0]).toEqual([ + { path: 'index.js', d: 'test', wrapTextNodes }, + ]); + expect(addInteractionClass.mock.calls[1]).toEqual([ + { path: 'index.js', d: 'console.log', wrapTextNodes }, + ]); }); it('does not call addInteractionClass when no data exists', () => { @@ -175,20 +180,20 @@ describe('Code navigation actions', () => { target = document.querySelector('.js-test'); }); - it('returns early when no data exists', (done) => { - testAction(actions.showDefinition, { target }, {}, [], [], done); + it('returns early when no data exists', () => { + return testAction(actions.showDefinition, { target }, {}, [], []); }); - it('commits SET_CURRENT_DEFINITION when target is not code navitation element', (done) => { - testAction(actions.showDefinition, { target }, { data: {} }, [], [], done); + it('commits SET_CURRENT_DEFINITION when target is not code navitation element', () => { + return testAction(actions.showDefinition, { target }, { data: {} }, [], []); }); - it('commits SET_CURRENT_DEFINITION with LSIF data', (done) => { + it('commits SET_CURRENT_DEFINITION with LSIF data', () => { target.classList.add('js-code-navigation'); target.setAttribute('data-line-index', '0'); target.setAttribute('data-char-index', '0'); - testAction( + return testAction( actions.showDefinition, { target }, { data: { 'index.js': { '0:0': { hover: 'test' } } } }, @@ -203,7 +208,6 @@ describe('Code navigation actions', () => { }, ], [], - done, ); }); diff --git a/spec/frontend/code_navigation/store/mutations_spec.js b/spec/frontend/code_navigation/store/mutations_spec.js index cb10729f4b6..b2f1b3bddfd 100644 --- a/spec/frontend/code_navigation/store/mutations_spec.js +++ b/spec/frontend/code_navigation/store/mutations_spec.js @@ -13,10 +13,12 @@ describe('Code navigation mutations', () => { mutations.SET_INITIAL_DATA(state, { blobs: ['test'], definitionPathPrefix: 'https://test.com/blob/main', + wrapTextNodes: true, }); expect(state.blobs).toEqual(['test']); expect(state.definitionPathPrefix).toBe('https://test.com/blob/main'); + expect(state.wrapTextNodes).toBe(true); }); }); diff --git a/spec/frontend/code_navigation/utils/index_spec.js b/spec/frontend/code_navigation/utils/index_spec.js index 6a01249d2a3..682c8bce8c5 100644 --- a/spec/frontend/code_navigation/utils/index_spec.js +++ b/spec/frontend/code_navigation/utils/index_spec.js @@ -45,14 +45,42 @@ describe('addInteractionClass', () => { ${0} | ${0} | ${0} ${0} | ${8} | ${2} ${1} | ${0} | ${0} + ${1} | ${0} | ${0} `( 'it sets code navigation attributes for line $line and character $char', ({ line, char, index }) => { - addInteractionClass('index.js', { start_line: line, start_char: char }); + addInteractionClass({ path: 'index.js', d: { start_line: line, start_char: char } }); expect(document.querySelectorAll(`#LC${line + 1} span`)[index].classList).toContain( 'js-code-navigation', ); }, ); + + describe('wrapTextNodes', () => { + beforeEach(() => { + setFixtures( + '<div data-path="index.js"><div class="blob-content"><div id="LC1" class="line"> Text </div></div></div>', + ); + }); + + const params = { path: 'index.js', d: { start_line: 0, start_char: 0 } }; + const findAllSpans = () => document.querySelectorAll('#LC1 span'); + + it('does not wrap text nodes by default', () => { + addInteractionClass(params); + const spans = findAllSpans(); + expect(spans.length).toBe(0); + }); + + it('wraps text nodes if wrapTextNodes is true', () => { + addInteractionClass({ ...params, wrapTextNodes: true }); + const spans = findAllSpans(); + + expect(spans.length).toBe(3); + expect(spans[0].textContent).toBe(' '); + expect(spans[1].textContent).toBe('Text'); + expect(spans[2].textContent).toBe(' '); + }); + }); }); diff --git a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js index 1a2e188e7ae..b1c8ba48475 100644 --- a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js +++ b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js @@ -1,7 +1,18 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { shallowMount } from '@vue/test-utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; import CommitBoxPipelineMiniGraph from '~/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue'; -import { mockStages } from './mock_data'; +import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql'; +import getPipelineStagesQuery from '~/projects/commit_box/info/graphql/queries/get_pipeline_stages.query.graphql'; +import { mockPipelineStagesQueryResponse, mockStages } from './mock_data'; + +jest.mock('~/flash'); + +Vue.use(VueApollo); describe('Commit box pipeline mini graph', () => { let wrapper; @@ -10,34 +21,36 @@ describe('Commit box pipeline mini graph', () => { const findUpstream = () => wrapper.findByTestId('commit-box-mini-graph-upstream'); const findDownstream = () => wrapper.findByTestId('commit-box-mini-graph-downstream'); - const createComponent = () => { + const stagesHandler = jest.fn().mockResolvedValue(mockPipelineStagesQueryResponse); + + const createComponent = ({ props = {} } = {}) => { + const handlers = [ + [getLinkedPipelinesQuery, {}], + [getPipelineStagesQuery, stagesHandler], + ]; + wrapper = extendedWrapper( shallowMount(CommitBoxPipelineMiniGraph, { propsData: { stages: mockStages, + ...props, }, - mocks: { - $apollo: { - queries: { - pipeline: { - loading: false, - }, - }, - }, - }, + apolloProvider: createMockApollo(handlers), }), ); - }; - beforeEach(() => { - createComponent(); - }); + return waitForPromises(); + }; afterEach(() => { wrapper.destroy(); }); describe('linked pipelines', () => { + beforeEach(async () => { + await createComponent(); + }); + it('should display the mini pipeine graph', () => { expect(findMiniGraph().exists()).toBe(true); }); @@ -47,4 +60,18 @@ describe('Commit box pipeline mini graph', () => { expect(findDownstream().exists()).toBe(false); }); }); + + describe('when data is mismatched', () => { + beforeEach(async () => { + await createComponent({ props: { stages: [] } }); + }); + + it('calls create flash with expected arguments', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem handling the pipeline data.', + captureError: true, + error: new Error('Rest stages and graphQl stages must be the same length'), + }); + }); + }); }); diff --git a/spec/frontend/commit/components/commit_box_pipeline_status_spec.js b/spec/frontend/commit/components/commit_box_pipeline_status_spec.js new file mode 100644 index 00000000000..db7b7b45397 --- /dev/null +++ b/spec/frontend/commit/components/commit_box_pipeline_status_spec.js @@ -0,0 +1,150 @@ +import { GlLoadingIcon, GlLink } 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 createFlash from '~/flash'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CommitBoxPipelineStatus from '~/projects/commit_box/info/components/commit_box_pipeline_status.vue'; +import { + COMMIT_BOX_POLL_INTERVAL, + PIPELINE_STATUS_FETCH_ERROR, +} from '~/projects/commit_box/info/constants'; +import getLatestPipelineStatusQuery from '~/projects/commit_box/info/graphql/queries/get_latest_pipeline_status.query.graphql'; +import * as graphQlUtils from '~/pipelines/components/graph/utils'; +import { mockPipelineStatusResponse } from '../mock_data'; + +const mockProvide = { + fullPath: 'root/ci-project', + iid: '46', + graphqlResourceEtag: '/api/graphql:pipelines/id/320', +}; + +Vue.use(VueApollo); + +jest.mock('~/flash'); + +describe('Commit box pipeline status', () => { + let wrapper; + + const statusSuccessHandler = jest.fn().mockResolvedValue(mockPipelineStatusResponse); + const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findStatusIcon = () => wrapper.findComponent(CiIcon); + const findPipelineLink = () => wrapper.findComponent(GlLink); + + const advanceToNextFetch = () => { + jest.advanceTimersByTime(COMMIT_BOX_POLL_INTERVAL); + }; + + const createMockApolloProvider = (handler) => { + const requestHandlers = [[getLatestPipelineStatusQuery, handler]]; + + return createMockApollo(requestHandlers); + }; + + const createComponent = (handler = statusSuccessHandler) => { + wrapper = shallowMount(CommitBoxPipelineStatus, { + provide: { + ...mockProvide, + }, + apolloProvider: createMockApolloProvider(handler), + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('loading state', () => { + it('should display loading state when loading', () => { + createComponent(); + + expect(findLoadingIcon().exists()).toBe(true); + expect(findStatusIcon().exists()).toBe(false); + }); + }); + + describe('loaded state', () => { + beforeEach(async () => { + createComponent(); + + await waitForPromises(); + }); + + it('should display pipeline status after the query is resolved successfully', async () => { + expect(findStatusIcon().exists()).toBe(true); + + expect(findLoadingIcon().exists()).toBe(false); + expect(createFlash).toHaveBeenCalledTimes(0); + }); + + it('should link to the latest pipeline', () => { + const { + data: { + project: { + pipeline: { + detailedStatus: { detailsPath }, + }, + }, + }, + } = mockPipelineStatusResponse; + + expect(findPipelineLink().attributes('href')).toBe(detailsPath); + }); + }); + + describe('error state', () => { + it('createFlash should show if there is an error fetching the pipeline status', async () => { + createComponent(failedHandler); + + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: PIPELINE_STATUS_FETCH_ERROR, + }); + }); + }); + + describe('polling', () => { + it('polling interval is set for pipeline stages', () => { + createComponent(); + + const expectedInterval = wrapper.vm.$apollo.queries.pipelineStatus.options.pollInterval; + + expect(expectedInterval).toBe(COMMIT_BOX_POLL_INTERVAL); + }); + + it('polls for pipeline status', async () => { + createComponent(); + + await waitForPromises(); + + expect(statusSuccessHandler).toHaveBeenCalledTimes(1); + + advanceToNextFetch(); + await waitForPromises(); + + expect(statusSuccessHandler).toHaveBeenCalledTimes(2); + + advanceToNextFetch(); + await waitForPromises(); + + expect(statusSuccessHandler).toHaveBeenCalledTimes(3); + }); + + it('toggles pipelineStatus polling with visibility check', async () => { + jest.spyOn(graphQlUtils, 'toggleQueryPollingByVisibility'); + + createComponent(); + + await waitForPromises(); + + expect(graphQlUtils.toggleQueryPollingByVisibility).toHaveBeenCalledWith( + wrapper.vm.$apollo.queries.pipelineStatus, + ); + }); + }); +}); diff --git a/spec/frontend/commit/mock_data.js b/spec/frontend/commit/mock_data.js index ef018a4fbd7..8db162c07c2 100644 --- a/spec/frontend/commit/mock_data.js +++ b/spec/frontend/commit/mock_data.js @@ -115,3 +115,49 @@ export const mockStages = [ dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=qa', }, ]; + +export const mockPipelineStagesQueryResponse = { + data: { + project: { + id: 'gid://gitlab/Project/20', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/320', + stages: { + nodes: [ + { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/409', + name: 'build', + detailedStatus: { + id: 'success-409-409', + group: 'success', + icon: 'status_success', + __typename: 'DetailedStatus', + }, + }, + ], + }, + }, + }, + }, +}; + +export const mockPipelineStatusResponse = { + data: { + project: { + id: 'gid://gitlab/Project/20', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/320', + detailedStatus: { + id: 'pending-320-320', + detailsPath: '/root/ci-project/-/pipelines/320', + icon: 'status_pending', + group: 'pending', + __typename: 'DetailedStatus', + }, + __typename: 'Pipeline', + }, + __typename: 'Project', + }, + }, +}; diff --git a/spec/frontend/commit/pipelines/pipelines_table_spec.js b/spec/frontend/commit/pipelines/pipelines_table_spec.js index 203a4d23160..9b01af1e585 100644 --- a/spec/frontend/commit/pipelines/pipelines_table_spec.js +++ b/spec/frontend/commit/pipelines/pipelines_table_spec.js @@ -120,18 +120,20 @@ describe('Pipelines table in Commits and Merge requests', () => { }); describe('pipeline badge counts', () => { - it('should receive update-pipelines-count event', (done) => { + it('should receive update-pipelines-count event', () => { const element = document.createElement('div'); document.body.appendChild(element); - element.addEventListener('update-pipelines-count', (event) => { - expect(event.detail.pipelineCount).toEqual(10); - done(); - }); + return new Promise((resolve) => { + element.addEventListener('update-pipelines-count', (event) => { + expect(event.detail.pipelineCount).toEqual(10); + resolve(); + }); - createComponent(); + createComponent(); - element.appendChild(wrapper.vm.$el); + element.appendChild(wrapper.vm.$el); + }); }); }); }); diff --git a/spec/frontend/commit/pipelines/utils_spec.js b/spec/frontend/commit/pipelines/utils_spec.js new file mode 100644 index 00000000000..472e35a6eb3 --- /dev/null +++ b/spec/frontend/commit/pipelines/utils_spec.js @@ -0,0 +1,59 @@ +import { formatStages } from '~/projects/commit_box/info/utils'; + +const graphqlStage = [ + { + __typename: 'CiStage', + name: 'deploy', + detailedStatus: { + __typename: 'DetailedStatus', + icon: 'status_success', + group: 'success', + id: 'success-409-409', + }, + }, +]; + +const restStage = [ + { + name: 'deploy', + title: 'deploy: passed', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/root/ci-project/-/pipelines/318#deploy', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/root/ci-project/-/pipelines/318#deploy', + dropdown_path: '/root/ci-project/-/pipelines/318/stage.json?stage=deploy', + }, +]; + +describe('Utils', () => { + it('combines REST and GraphQL stages correctly for component', () => { + expect(formatStages(graphqlStage, restStage)).toEqual([ + { + dropdown_path: '/root/ci-project/-/pipelines/318/stage.json?stage=deploy', + name: 'deploy', + status: { + __typename: 'DetailedStatus', + group: 'success', + icon: 'status_success', + id: 'success-409-409', + }, + title: 'deploy: passed', + }, + ]); + }); + + it('throws an error if arrays are not the same length', () => { + expect(() => { + formatStages(graphqlStage, []); + }).toThrow('Rest stages and graphQl stages must be the same length'); + }); +}); diff --git a/spec/frontend/commits_spec.js b/spec/frontend/commits_spec.js index 8189ebe6e55..a049a6997f0 100644 --- a/spec/frontend/commits_spec.js +++ b/spec/frontend/commits_spec.js @@ -70,29 +70,17 @@ describe('Commits List', () => { mock.restore(); }); - it('should save the last search string', (done) => { + it('should save the last search string', async () => { commitsList.searchField.val('GitLab'); - commitsList - .filterResults() - .then(() => { - expect(ajaxSpy).toHaveBeenCalled(); - expect(commitsList.lastSearch).toEqual('GitLab'); - - done(); - }) - .catch(done.fail); + await commitsList.filterResults(); + expect(ajaxSpy).toHaveBeenCalled(); + expect(commitsList.lastSearch).toEqual('GitLab'); }); - it('should not make ajax call if the input does not change', (done) => { - commitsList - .filterResults() - .then(() => { - expect(ajaxSpy).not.toHaveBeenCalled(); - expect(commitsList.lastSearch).toEqual(''); - - done(); - }) - .catch(done.fail); + it('should not make ajax call if the input does not change', async () => { + await commitsList.filterResults(); + expect(ajaxSpy).not.toHaveBeenCalled(); + expect(commitsList.lastSearch).toEqual(''); }); }); }); diff --git a/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap b/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap index c2fa6556847..d9f161b47b1 100644 --- a/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap +++ b/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap @@ -12,7 +12,7 @@ exports[`Confidential merge request project form group component renders empty s <!----> <p - class="text-muted mt-1 mb-0" + class="gl-text-gray-600 gl-mt-1 gl-mb-0" > No forks are available to you. @@ -27,7 +27,7 @@ exports[`Confidential merge request project form group component renders empty s </a> and set the fork's visibility to private. <gl-link-stub - class="w-auto p-0 d-inline-block text-primary bg-transparent" + class="gl-w-auto gl-p-0 gl-display-inline-block gl-bg-transparent" href="/help" target="_blank" > @@ -62,13 +62,13 @@ exports[`Confidential merge request project form group component renders fork dr /> <p - class="text-muted mt-1 mb-0" + class="gl-text-gray-600 gl-mt-1 gl-mb-0" > To protect this issue's confidentiality, a private fork of this project was selected. <gl-link-stub - class="w-auto p-0 d-inline-block text-primary bg-transparent" + class="gl-w-auto gl-p-0 gl-display-inline-block gl-bg-transparent" href="/help" target="_blank" > diff --git a/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js b/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js new file mode 100644 index 00000000000..074c311495f --- /dev/null +++ b/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js @@ -0,0 +1,142 @@ +import { BubbleMenu } from '@tiptap/vue-2'; +import { GlButton, GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import Vue from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import CodeBlockBubbleMenu from '~/content_editor/components/code_block_bubble_menu.vue'; +import eventHubFactory from '~/helpers/event_hub_factory'; +import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; +import codeBlockLanguageLoader from '~/content_editor/services/code_block_language_loader'; +import { createTestEditor, emitEditorEvent } from '../test_utils'; + +describe('content_editor/components/code_block_bubble_menu', () => { + let wrapper; + let tiptapEditor; + let bubbleMenu; + let eventHub; + + const buildEditor = () => { + tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] }); + eventHub = eventHubFactory(); + }; + + const buildWrapper = () => { + wrapper = mountExtended(CodeBlockBubbleMenu, { + provide: { + tiptapEditor, + eventHub, + }, + }); + }; + + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownItemsData = () => + findDropdownItems().wrappers.map((x) => ({ + text: x.text(), + visible: x.isVisible(), + checked: x.props('isChecked'), + })); + + beforeEach(() => { + buildEditor(); + buildWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders bubble menu component', async () => { + tiptapEditor.commands.insertContent('<pre>test</pre>'); + bubbleMenu = wrapper.findComponent(BubbleMenu); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + expect(bubbleMenu.props('editor')).toBe(tiptapEditor); + expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base']); + }); + + it('selects plaintext language by default', async () => { + tiptapEditor.commands.insertContent('<pre>test</pre>'); + bubbleMenu = wrapper.findComponent(BubbleMenu); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Plain text'); + }); + + it('selects appropriate language based on the code block', async () => { + tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); + bubbleMenu = wrapper.findComponent(BubbleMenu); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Javascript'); + }); + + it("selects Custom (syntax) if the language doesn't exist in the list", async () => { + tiptapEditor.commands.insertContent('<pre lang="nomnoml">test</pre>'); + bubbleMenu = wrapper.findComponent(BubbleMenu); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Custom (nomnoml)'); + }); + + it('delete button deletes the code block', async () => { + tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); + + await wrapper.findComponent(GlButton).vm.$emit('click'); + + expect(tiptapEditor.getText()).toBe(''); + }); + + describe('when opened and search is changed', () => { + beforeEach(async () => { + tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); + + wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'js'); + + await Vue.nextTick(); + }); + + it('shows dropdown items', () => { + expect(findDropdownItemsData()).toEqual([ + { text: 'Javascript', visible: true, checked: true }, + { text: 'Java', visible: true, checked: false }, + { text: 'Javascript', visible: false, checked: false }, + { text: 'JSON', visible: true, checked: false }, + ]); + }); + + describe('when dropdown item is clicked', () => { + beforeEach(async () => { + jest.spyOn(codeBlockLanguageLoader, 'loadLanguages').mockResolvedValue(); + + findDropdownItems().at(1).vm.$emit('click'); + + await Vue.nextTick(); + }); + + it('loads language', () => { + expect(codeBlockLanguageLoader.loadLanguages).toHaveBeenCalledWith(['java']); + }); + + it('sets code block', () => { + expect(tiptapEditor.getJSON()).toMatchObject({ + content: [ + { + type: 'codeBlock', + attrs: { + language: 'java', + }, + }, + ], + }); + }); + + it('updates selected dropdown', () => { + expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Java'); + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js b/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js index e44a7fa4ddb..192ddee78c6 100644 --- a/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js +++ b/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js @@ -9,7 +9,7 @@ import { } from '~/content_editor/constants'; import { createTestEditor } from '../test_utils'; -describe('content_editor/components/top_toolbar', () => { +describe('content_editor/components/formatting_bubble_menu', () => { let wrapper; let trackingSpy; let tiptapEditor; diff --git a/spec/frontend/content_editor/components/wrappers/image_spec.js b/spec/frontend/content_editor/components/wrappers/media_spec.js index 7b057f9cabc..3e95e2f3914 100644 --- a/spec/frontend/content_editor/components/wrappers/image_spec.js +++ b/spec/frontend/content_editor/components/wrappers/media_spec.js @@ -1,21 +1,24 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { NodeViewWrapper } from '@tiptap/vue-2'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import ImageWrapper from '~/content_editor/components/wrappers/image.vue'; +import MediaWrapper from '~/content_editor/components/wrappers/media.vue'; -describe('content/components/wrappers/image', () => { +describe('content/components/wrappers/media', () => { let wrapper; const createWrapper = async (nodeAttrs = {}) => { - wrapper = shallowMountExtended(ImageWrapper, { + wrapper = shallowMountExtended(MediaWrapper, { propsData: { node: { attrs: nodeAttrs, + type: { + name: 'image', + }, }, }, }); }; - const findImage = () => wrapper.findByTestId('image'); + const findMedia = () => wrapper.findByTestId('media'); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); afterEach(() => { @@ -33,7 +36,7 @@ describe('content/components/wrappers/image', () => { createWrapper({ src }); - expect(findImage().attributes().src).toBe(src); + expect(findMedia().attributes().src).toBe(src); }); describe('when uploading', () => { @@ -45,8 +48,8 @@ describe('content/components/wrappers/image', () => { expect(findLoadingIcon().exists()).toBe(true); }); - it('adds gl-opacity-5 class selector to image', () => { - expect(findImage().classes()).toContain('gl-opacity-5'); + it('adds gl-opacity-5 class selector to the media tag', () => { + expect(findMedia().classes()).toContain('gl-opacity-5'); }); }); @@ -59,8 +62,8 @@ describe('content/components/wrappers/image', () => { expect(findLoadingIcon().exists()).toBe(false); }); - it('does not add gl-opacity-5 class selector to image', () => { - expect(findImage().classes()).not.toContain('gl-opacity-5'); + it('does not add gl-opacity-5 class selector to the media tag', () => { + expect(findMedia().classes()).not.toContain('gl-opacity-5'); }); }); }); diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js index ec67545cf17..d3c42104e47 100644 --- a/spec/frontend/content_editor/extensions/attachment_spec.js +++ b/spec/frontend/content_editor/extensions/attachment_spec.js @@ -1,7 +1,10 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; import Attachment from '~/content_editor/extensions/attachment'; import Image from '~/content_editor/extensions/image'; +import Audio from '~/content_editor/extensions/audio'; +import Video from '~/content_editor/extensions/video'; import Link from '~/content_editor/extensions/link'; import Loading from '~/content_editor/extensions/loading'; import { VARIANT_DANGER } from '~/flash'; @@ -14,6 +17,23 @@ const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `<p data-sourcepos="1:1-1:27" dir="au <img alt="test-file" class="lazy" data-src="/group1/project1/-/wikis/test-file.png" data-canonical-src="test-file.png"> </a> </p>`; + +const PROJECT_WIKI_ATTACHMENT_VIDEO_HTML = `<p data-sourcepos="1:1-1:132" dir="auto"> + <span class="media-container video-container"> + <video src="/group1/project1/-/wikis/test-file.mp4" controls="true" data-setup="{}" data-title="test-file" width="400" preload="metadata" data-canonical-src="test-file.mp4"> + </video> + <a href="/himkp/test/-/wikis/test-file.mp4" target="_blank" rel="noopener noreferrer" title="Download 'test-file'" data-canonical-src="test-file.mp4">test-file</a> + </span> +</p>`; + +const PROJECT_WIKI_ATTACHMENT_AUDIO_HTML = `<p data-sourcepos="3:1-3:74" dir="auto"> + <span class="media-container audio-container"> + <audio src="/himkp/test/-/wikis/test-file.mp3" controls="true" data-setup="{}" data-title="test-file" data-canonical-src="test-file.mp3"> + </audio> + <a href="/himkp/test/-/wikis/test-file.mp3" target="_blank" rel="noopener noreferrer" title="Download 'test-file'" data-canonical-src="test-file.mp3">test-file</a> + </span> +</p>`; + const PROJECT_WIKI_ATTACHMENT_LINK_HTML = `<p data-sourcepos="1:1-1:26" dir="auto"> <a href="/group1/project1/-/wikis/test-file.zip" data-canonical-src="test-file.zip">test-file</a> </p>`; @@ -23,6 +43,8 @@ describe('content_editor/extensions/attachment', () => { let doc; let p; let image; + let audio; + let video; let loading; let link; let renderMarkdown; @@ -31,15 +53,18 @@ describe('content_editor/extensions/attachment', () => { const uploadsPath = '/uploads/'; const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' }); + const audioFile = new File(['foo'], 'test-file.mp3', { type: 'audio/mpeg' }); + const videoFile = new File(['foo'], 'test-file.mp4', { type: 'video/mp4' }); const attachmentFile = new File(['foo'], 'test-file.zip', { type: 'application/zip' }); const expectDocumentAfterTransaction = ({ number, expectedDoc, action }) => { return new Promise((resolve) => { let counter = 1; - const handleTransaction = () => { + const handleTransaction = async () => { if (counter === number) { expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); tiptapEditor.off('update', handleTransaction); + await waitForPromises(); resolve(); } @@ -60,18 +85,22 @@ describe('content_editor/extensions/attachment', () => { Loading, Link, Image, + Audio, + Video, Attachment.configure({ renderMarkdown, uploadsPath, eventHub }), ], }); ({ - builders: { doc, p, image, loading, link }, + builders: { doc, p, image, audio, video, loading, link }, } = createDocBuilder({ tiptapEditor, names: { loading: { markType: Loading.name }, image: { nodeType: Image.name }, link: { nodeType: Link.name }, + audio: { nodeType: Audio.name }, + video: { nodeType: Video.name }, }, })); @@ -103,17 +132,22 @@ describe('content_editor/extensions/attachment', () => { tiptapEditor.commands.setContent(initialDoc.toJSON()); }); - describe('when the file has image mime type', () => { - const base64EncodedFile = 'data:image/png;base64,Zm9v'; + describe.each` + nodeType | mimeType | html | file | mediaType + ${'image'} | ${'image/png'} | ${PROJECT_WIKI_ATTACHMENT_IMAGE_HTML} | ${imageFile} | ${(attrs) => image(attrs)} + ${'audio'} | ${'audio/mpeg'} | ${PROJECT_WIKI_ATTACHMENT_AUDIO_HTML} | ${audioFile} | ${(attrs) => audio(attrs)} + ${'video'} | ${'video/mp4'} | ${PROJECT_WIKI_ATTACHMENT_VIDEO_HTML} | ${videoFile} | ${(attrs) => video(attrs)} + `('when the file has $nodeType mime type', ({ mimeType, html, file, mediaType }) => { + const base64EncodedFile = `data:${mimeType};base64,Zm9v`; beforeEach(() => { - renderMarkdown.mockResolvedValue(PROJECT_WIKI_ATTACHMENT_IMAGE_HTML); + renderMarkdown.mockResolvedValue(html); }); describe('when uploading succeeds', () => { const successResponse = { link: { - markdown: '![test-file](test-file.png)', + markdown: `![test-file](${file.name})`, }, }; @@ -121,21 +155,21 @@ describe('content_editor/extensions/attachment', () => { mock.onPost().reply(httpStatus.OK, successResponse); }); - it('inserts an image with src set to the encoded image file and uploading true', async () => { - const expectedDoc = doc(p(image({ uploading: true, src: base64EncodedFile }))); + it('inserts a media content with src set to the encoded content and uploading true', async () => { + const expectedDoc = doc(p(mediaType({ uploading: true, src: base64EncodedFile }))); await expectDocumentAfterTransaction({ number: 1, expectedDoc, - action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }), + action: () => tiptapEditor.commands.uploadAttachment({ file }), }); }); - it('updates the inserted image with canonicalSrc when upload is successful', async () => { + it('updates the inserted content with canonicalSrc when upload is successful', async () => { const expectedDoc = doc( p( - image({ - canonicalSrc: 'test-file.png', + mediaType({ + canonicalSrc: file.name, src: base64EncodedFile, alt: 'test-file', uploading: false, @@ -146,7 +180,7 @@ describe('content_editor/extensions/attachment', () => { await expectDocumentAfterTransaction({ number: 2, expectedDoc, - action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }), + action: () => tiptapEditor.commands.uploadAttachment({ file }), }); }); }); @@ -162,17 +196,19 @@ describe('content_editor/extensions/attachment', () => { await expectDocumentAfterTransaction({ number: 2, expectedDoc, - action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }), + action: () => tiptapEditor.commands.uploadAttachment({ file }), }); }); - it('emits an alert event that includes an error message', (done) => { - tiptapEditor.commands.uploadAttachment({ file: imageFile }); + it('emits an alert event that includes an error message', () => { + tiptapEditor.commands.uploadAttachment({ file }); - eventHub.$on('alert', ({ message, variant }) => { - expect(variant).toBe(VARIANT_DANGER); - expect(message).toBe('An error occurred while uploading the image. Please try again.'); - done(); + return new Promise((resolve) => { + eventHub.$on('alert', ({ message, variant }) => { + expect(variant).toBe(VARIANT_DANGER); + expect(message).toBe('An error occurred while uploading the file. Please try again.'); + resolve(); + }); }); }); }); @@ -243,13 +279,12 @@ describe('content_editor/extensions/attachment', () => { }); }); - it('emits an alert event that includes an error message', (done) => { + it('emits an alert event that includes an error message', () => { tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); eventHub.$on('alert', ({ message, variant }) => { expect(variant).toBe(VARIANT_DANGER); expect(message).toBe('An error occurred while uploading the file. Please try again.'); - done(); }); }); }); diff --git a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js index 05fa0f79ef0..02e5b1dc271 100644 --- a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js +++ b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js @@ -1,5 +1,5 @@ import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; -import { createTestEditor } from '../test_utils'; +import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils'; const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true"> <code> @@ -12,34 +12,78 @@ const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language describe('content_editor/extensions/code_block_highlight', () => { let parsedCodeBlockHtmlFixture; let tiptapEditor; + let doc; + let codeBlock; + let languageLoader; const parseHTML = (html) => new DOMParser().parseFromString(html, 'text/html'); const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre'); beforeEach(() => { - tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] }); - parsedCodeBlockHtmlFixture = parseHTML(CODE_BLOCK_HTML); + languageLoader = { loadLanguages: jest.fn() }; + tiptapEditor = createTestEditor({ + extensions: [CodeBlockHighlight.configure({ languageLoader })], + }); - tiptapEditor.commands.setContent(CODE_BLOCK_HTML); + ({ + builders: { doc, codeBlock }, + } = createDocBuilder({ + tiptapEditor, + names: { + codeBlock: { nodeType: CodeBlockHighlight.name }, + }, + })); }); - it('extracts language and params attributes from Markdown API output', () => { - const language = preElement().getAttribute('lang'); + describe('when parsing HTML', () => { + beforeEach(() => { + parsedCodeBlockHtmlFixture = parseHTML(CODE_BLOCK_HTML); - expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({ - language, + tiptapEditor.commands.setContent(CODE_BLOCK_HTML); + }); + it('extracts language and params attributes from Markdown API output', () => { + const language = preElement().getAttribute('lang'); + + expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({ + language, + }); + }); + + it('adds code, highlight, and js-syntax-highlight to code block element', () => { + const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); + + expect(editorHtmlOutput.classList.toString()).toContain('code highlight js-syntax-highlight'); }); - }); - it('adds code, highlight, and js-syntax-highlight to code block element', () => { - const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); + it('adds content-editor-code-block class to the pre element', () => { + const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); - expect(editorHtmlOutput.classList.toString()).toContain('code highlight js-syntax-highlight'); + expect(editorHtmlOutput.classList.toString()).toContain('content-editor-code-block'); + }); }); - it('adds content-editor-code-block class to the pre element', () => { - const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); + describe.each` + inputRule + ${'```'} + ${'~~~'} + `('when typing $inputRule input rule', ({ inputRule }) => { + const language = 'javascript'; + + beforeEach(() => { + triggerNodeInputRule({ + tiptapEditor, + inputRuleText: `${inputRule}${language} `, + }); + }); + + it('creates a new code block and loads related language', () => { + const expectedDoc = doc(codeBlock({ language })); - expect(editorHtmlOutput.classList.toString()).toContain('content-editor-code-block'); + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + + it('loads language when language loader is available', () => { + expect(languageLoader.loadLanguages).toHaveBeenCalledWith([language]); + }); }); }); diff --git a/spec/frontend/content_editor/extensions/frontmatter_spec.js b/spec/frontend/content_editor/extensions/frontmatter_spec.js index a8cbad6ef81..4f80c2cb81a 100644 --- a/spec/frontend/content_editor/extensions/frontmatter_spec.js +++ b/spec/frontend/content_editor/extensions/frontmatter_spec.js @@ -23,7 +23,7 @@ describe('content_editor/extensions/frontmatter', () => { }); it('does not insert a frontmatter block when executing code block input rule', () => { - const expectedDoc = doc(codeBlock('')); + const expectedDoc = doc(codeBlock({ language: 'plaintext' }, '')); const inputRuleText = '``` '; triggerNodeInputRule({ tiptapEditor, inputRuleText }); diff --git a/spec/frontend/content_editor/services/code_block_language_loader_spec.js b/spec/frontend/content_editor/services/code_block_language_loader_spec.js new file mode 100644 index 00000000000..905c1685b94 --- /dev/null +++ b/spec/frontend/content_editor/services/code_block_language_loader_spec.js @@ -0,0 +1,120 @@ +import codeBlockLanguageBlocker from '~/content_editor/services/code_block_language_loader'; +import waitForPromises from 'helpers/wait_for_promises'; +import { backtickInputRegex } from '~/content_editor/extensions/code_block_highlight'; + +describe('content_editor/services/code_block_language_loader', () => { + let languageLoader; + let lowlight; + + beforeEach(() => { + lowlight = { + languages: [], + registerLanguage: jest + .fn() + .mockImplementation((language) => lowlight.languages.push(language)), + registered: jest.fn().mockImplementation((language) => lowlight.languages.includes(language)), + }; + languageLoader = codeBlockLanguageBlocker; + languageLoader.lowlight = lowlight; + }); + + describe('findLanguageBySyntax', () => { + it.each` + syntax | language + ${'javascript'} | ${{ syntax: 'javascript', label: 'Javascript' }} + ${'js'} | ${{ syntax: 'javascript', label: 'Javascript' }} + ${'jsx'} | ${{ syntax: 'javascript', label: 'Javascript' }} + `('returns a language by syntax and its variants', ({ syntax, language }) => { + expect(languageLoader.findLanguageBySyntax(syntax)).toMatchObject(language); + }); + + it('returns Custom (syntax) if the language does not exist', () => { + expect(languageLoader.findLanguageBySyntax('foobar')).toMatchObject({ + syntax: 'foobar', + label: 'Custom (foobar)', + }); + }); + + it('returns plaintext if no syntax is passed', () => { + expect(languageLoader.findLanguageBySyntax('')).toMatchObject({ + syntax: 'plaintext', + label: 'Plain text', + }); + }); + }); + + describe('filterLanguages', () => { + it('filters languages by the given search term', () => { + expect(languageLoader.filterLanguages('ts')).toEqual([ + { label: 'Device Tree', syntax: 'dts' }, + { label: 'Kotlin', syntax: 'kotlin', variants: 'kt, kts' }, + { label: 'TypeScript', syntax: 'typescript', variants: 'ts, tsx' }, + ]); + }); + }); + + describe('loadLanguages', () => { + it('loads highlight.js language packages identified by a list of languages', async () => { + const languages = ['javascript', 'ruby']; + + await languageLoader.loadLanguages(languages); + + languages.forEach((language) => { + expect(lowlight.registerLanguage).toHaveBeenCalledWith(language, expect.any(Function)); + }); + }); + + describe('when language is already registered', () => { + it('does not load the language again', async () => { + const languages = ['javascript']; + + await languageLoader.loadLanguages(languages); + await languageLoader.loadLanguages(languages); + + expect(lowlight.registerLanguage).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('loadLanguagesFromDOM', () => { + it('loads highlight.js language packages identified by pre tags in a DOM fragment', async () => { + const parser = new DOMParser(); + const { body } = parser.parseFromString( + ` + <pre lang="javascript"></pre> + <pre lang="ruby"></pre> + `, + 'text/html', + ); + + await languageLoader.loadLanguagesFromDOM(body); + + expect(lowlight.registerLanguage).toHaveBeenCalledWith('javascript', expect.any(Function)); + expect(lowlight.registerLanguage).toHaveBeenCalledWith('ruby', expect.any(Function)); + }); + }); + + describe('loadLanguageFromInputRule', () => { + it('loads highlight.js language packages identified from the input rule', async () => { + const match = new RegExp(backtickInputRegex).exec('```js '); + const attrs = languageLoader.loadLanguageFromInputRule(match); + + await waitForPromises(); + + expect(attrs).toEqual({ language: 'javascript' }); + expect(lowlight.registerLanguage).toHaveBeenCalledWith('javascript', expect.any(Function)); + }); + }); + + describe('isLanguageLoaded', () => { + it('returns true when a language is registered', async () => { + const language = 'javascript'; + + expect(languageLoader.isLanguageLoaded(language)).toBe(false); + + await languageLoader.loadLanguages([language]); + + expect(languageLoader.isLanguageLoaded(language)).toBe(true); + }); + }); +}); diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js index 3bc72b13302..5b7a27b501d 100644 --- a/spec/frontend/content_editor/services/content_editor_spec.js +++ b/spec/frontend/content_editor/services/content_editor_spec.js @@ -11,6 +11,7 @@ describe('content_editor/services/content_editor', () => { let contentEditor; let serializer; let deserializer; + let languageLoader; let eventHub; let doc; let p; @@ -27,8 +28,15 @@ describe('content_editor/services/content_editor', () => { serializer = { deserialize: jest.fn() }; deserializer = { deserialize: jest.fn() }; + languageLoader = { loadLanguagesFromDOM: jest.fn() }; eventHub = eventHubFactory(); - contentEditor = new ContentEditor({ tiptapEditor, serializer, deserializer, eventHub }); + contentEditor = new ContentEditor({ + tiptapEditor, + serializer, + deserializer, + eventHub, + languageLoader, + }); }); describe('.dispose', () => { @@ -43,10 +51,12 @@ describe('content_editor/services/content_editor', () => { describe('when setSerializedContent succeeds', () => { let document; + const dom = {}; + const testMarkdown = '**bold text**'; beforeEach(() => { document = doc(p('document')); - deserializer.deserialize.mockResolvedValueOnce({ document }); + deserializer.deserialize.mockResolvedValueOnce({ document, dom }); }); it('emits loadingContent and loadingSuccess event in the eventHub', () => { @@ -59,14 +69,20 @@ describe('content_editor/services/content_editor', () => { expect(loadingContentEmitted).toBe(true); }); - contentEditor.setSerializedContent('**bold text**'); + contentEditor.setSerializedContent(testMarkdown); }); it('sets the deserialized document in the tiptap editor object', async () => { - await contentEditor.setSerializedContent('**bold text**'); + await contentEditor.setSerializedContent(testMarkdown); expect(contentEditor.tiptapEditor.state.doc.toJSON()).toEqual(document.toJSON()); }); + + it('passes deserialized DOM document to language loader', async () => { + await contentEditor.setSerializedContent(testMarkdown); + + expect(languageLoader.loadLanguagesFromDOM).toHaveBeenCalledWith(dom); + }); }); describe('when setSerializedContent fails', () => { diff --git a/spec/frontend/contributors/store/actions_spec.js b/spec/frontend/contributors/store/actions_spec.js index a4054ab1fc8..ef0ff8ca208 100644 --- a/spec/frontend/contributors/store/actions_spec.js +++ b/spec/frontend/contributors/store/actions_spec.js @@ -17,10 +17,14 @@ describe('Contributors store actions', () => { mock = new MockAdapter(axios); }); - it('should commit SET_CHART_DATA with received response', (done) => { + afterEach(() => { + mock.restore(); + }); + + it('should commit SET_CHART_DATA with received response', () => { mock.onGet().reply(200, chartData); - testAction( + return testAction( actions.fetchChartData, { endpoint }, {}, @@ -30,30 +34,22 @@ describe('Contributors store actions', () => { { type: types.SET_LOADING_STATE, payload: false }, ], [], - () => { - mock.restore(); - done(); - }, ); }); - it('should show flash on API error', (done) => { + it('should show flash on API error', async () => { mock.onGet().reply(400, 'Not Found'); - testAction( + await testAction( actions.fetchChartData, { endpoint }, {}, [{ type: types.SET_LOADING_STATE, payload: true }], [], - () => { - expect(createFlash).toHaveBeenCalledWith({ - message: expect.stringMatching('error'), - }); - mock.restore(); - done(); - }, ); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringMatching('error'), + }); }); }); }); diff --git a/spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js b/spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js index 55c502b96bb..c365cb6a9f4 100644 --- a/spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js +++ b/spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js @@ -14,53 +14,49 @@ import { describe('GCP Cluster Dropdown Store Actions', () => { describe('setProject', () => { - it('should set project', (done) => { - testAction( + it('should set project', () => { + return testAction( actions.setProject, selectedProjectMock, { selectedProject: {} }, [{ type: 'SET_PROJECT', payload: selectedProjectMock }], [], - done, ); }); }); describe('setZone', () => { - it('should set zone', (done) => { - testAction( + it('should set zone', () => { + return testAction( actions.setZone, selectedZoneMock, { selectedZone: '' }, [{ type: 'SET_ZONE', payload: selectedZoneMock }], [], - done, ); }); }); describe('setMachineType', () => { - it('should set machine type', (done) => { - testAction( + it('should set machine type', () => { + return testAction( actions.setMachineType, selectedMachineTypeMock, { selectedMachineType: '' }, [{ type: 'SET_MACHINE_TYPE', payload: selectedMachineTypeMock }], [], - done, ); }); }); describe('setIsValidatingProjectBilling', () => { - it('should set machine type', (done) => { - testAction( + it('should set machine type', () => { + return testAction( actions.setIsValidatingProjectBilling, true, { isValidatingProjectBilling: null }, [{ type: 'SET_IS_VALIDATING_PROJECT_BILLING', payload: true }], [], - done, ); }); }); @@ -94,8 +90,8 @@ describe('GCP Cluster Dropdown Store Actions', () => { }); describe('validateProjectBilling', () => { - it('checks project billing status from Google API', (done) => { - testAction( + it('checks project billing status from Google API', () => { + return testAction( actions.validateProjectBilling, true, { @@ -110,7 +106,6 @@ describe('GCP Cluster Dropdown Store Actions', () => { { type: 'SET_PROJECT_BILLING_STATUS', payload: true }, ], [{ type: 'setIsValidatingProjectBilling', payload: false }], - done, ); }); }); diff --git a/spec/frontend/crm/contact_form_spec.js b/spec/frontend/crm/contact_form_spec.js deleted file mode 100644 index 0edab4f5ec5..00000000000 --- a/spec/frontend/crm/contact_form_spec.js +++ /dev/null @@ -1,157 +0,0 @@ -import { GlAlert } from '@gitlab/ui'; -import Vue from 'vue'; -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 ContactForm from '~/crm/components/contact_form.vue'; -import createContactMutation from '~/crm/components/queries/create_contact.mutation.graphql'; -import updateContactMutation from '~/crm/components/queries/update_contact.mutation.graphql'; -import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql'; -import { - createContactMutationErrorResponse, - createContactMutationResponse, - getGroupContactsQueryResponse, - updateContactMutationErrorResponse, - updateContactMutationResponse, -} from './mock_data'; - -describe('Customer relations contact form component', () => { - Vue.use(VueApollo); - let wrapper; - let fakeApollo; - let mutation; - let queryHandler; - - const findSaveContactButton = () => wrapper.findByTestId('save-contact-button'); - const findCancelButton = () => wrapper.findByTestId('cancel-button'); - const findForm = () => wrapper.find('form'); - const findError = () => wrapper.findComponent(GlAlert); - - const mountComponent = ({ mountFunction = shallowMountExtended, editForm = false } = {}) => { - fakeApollo = createMockApollo([[mutation, queryHandler]]); - fakeApollo.clients.defaultClient.cache.writeQuery({ - query: getGroupContactsQuery, - variables: { groupFullPath: 'flightjs' }, - data: getGroupContactsQueryResponse.data, - }); - const propsData = { drawerOpen: true }; - if (editForm) - propsData.contact = { firstName: 'First', lastName: 'Last', email: 'email@example.com' }; - wrapper = mountFunction(ContactForm, { - provide: { groupId: 26, groupFullPath: 'flightjs' }, - apolloProvider: fakeApollo, - propsData, - }); - }; - - beforeEach(() => { - mutation = createContactMutation; - queryHandler = jest.fn().mockResolvedValue(createContactMutationResponse); - }); - - afterEach(() => { - wrapper.destroy(); - fakeApollo = null; - }); - - describe('Save contact button', () => { - it('should be disabled when required fields are empty', () => { - mountComponent(); - - expect(findSaveContactButton().props('disabled')).toBe(true); - }); - - it('should not be disabled when required fields have values', async () => { - mountComponent(); - - wrapper.find('#contact-first-name').vm.$emit('input', 'A'); - wrapper.find('#contact-last-name').vm.$emit('input', 'B'); - wrapper.find('#contact-email').vm.$emit('input', 'C'); - await waitForPromises(); - - expect(findSaveContactButton().props('disabled')).toBe(false); - }); - }); - - it("should emit 'close' when cancel button is clicked", () => { - mountComponent(); - - findCancelButton().vm.$emit('click'); - - expect(wrapper.emitted().close).toBeTruthy(); - }); - - describe('when create mutation is successful', () => { - it("should emit 'close'", async () => { - mountComponent(); - - findForm().trigger('submit'); - await waitForPromises(); - - expect(wrapper.emitted().close).toBeTruthy(); - }); - }); - - describe('when create mutation fails', () => { - it('should show error on reject', async () => { - queryHandler = jest.fn().mockRejectedValue('ERROR'); - mountComponent(); - - findForm().trigger('submit'); - await waitForPromises(); - - expect(findError().exists()).toBe(true); - }); - - it('should show error on error response', async () => { - queryHandler = jest.fn().mockResolvedValue(createContactMutationErrorResponse); - mountComponent(); - - findForm().trigger('submit'); - await waitForPromises(); - - expect(findError().exists()).toBe(true); - expect(findError().text()).toBe('create contact is invalid.'); - }); - }); - - describe('when update mutation is successful', () => { - it("should emit 'close'", async () => { - mutation = updateContactMutation; - queryHandler = jest.fn().mockResolvedValue(updateContactMutationResponse); - mountComponent({ editForm: true }); - - findForm().trigger('submit'); - await waitForPromises(); - - expect(wrapper.emitted().close).toBeTruthy(); - }); - }); - - describe('when update mutation fails', () => { - beforeEach(() => { - mutation = updateContactMutation; - }); - - it('should show error on reject', async () => { - queryHandler = jest.fn().mockRejectedValue('ERROR'); - mountComponent({ editForm: true }); - findForm().trigger('submit'); - await waitForPromises(); - - expect(findError().exists()).toBe(true); - }); - - it('should show error on error response', async () => { - queryHandler = jest.fn().mockResolvedValue(updateContactMutationErrorResponse); - mountComponent({ editForm: true }); - - findForm().trigger('submit'); - await waitForPromises(); - - expect(findError().exists()).toBe(true); - expect(findError().text()).toBe('update contact is invalid.'); - }); - }); -}); diff --git a/spec/frontend/crm/contact_form_wrapper_spec.js b/spec/frontend/crm/contact_form_wrapper_spec.js new file mode 100644 index 00000000000..6307889a7aa --- /dev/null +++ b/spec/frontend/crm/contact_form_wrapper_spec.js @@ -0,0 +1,88 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ContactFormWrapper from '~/crm/contacts/components/contact_form_wrapper.vue'; +import ContactForm from '~/crm/components/form.vue'; +import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql'; +import createContactMutation from '~/crm/contacts/components/graphql/create_contact.mutation.graphql'; +import updateContactMutation from '~/crm/contacts/components/graphql/update_contact.mutation.graphql'; + +describe('Customer relations contact form wrapper', () => { + let wrapper; + + const findContactForm = () => wrapper.findComponent(ContactForm); + + const $apollo = { + queries: { + contacts: { + loading: false, + }, + }, + }; + const $route = { + params: { + id: 7, + }, + }; + const contacts = [{ id: 'gid://gitlab/CustomerRelations::Contact/7' }]; + + const mountComponent = ({ isEditMode = false } = {}) => { + wrapper = shallowMountExtended(ContactFormWrapper, { + propsData: { + isEditMode, + }, + provide: { + groupFullPath: 'flightjs', + groupId: 26, + }, + mocks: { + $apollo, + $route, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('in edit mode', () => { + it('should render contact form with correct props', () => { + mountComponent({ isEditMode: true }); + + const contactForm = findContactForm(); + expect(contactForm.props('fields')).toHaveLength(5); + expect(contactForm.props('title')).toBe('Edit contact'); + expect(contactForm.props('successMessage')).toBe('Contact has been updated.'); + expect(contactForm.props('mutation')).toBe(updateContactMutation); + expect(contactForm.props('getQuery')).toMatchObject({ + query: getGroupContactsQuery, + variables: { groupFullPath: 'flightjs' }, + }); + expect(contactForm.props('getQueryNodePath')).toBe('group.contacts'); + expect(contactForm.props('existingId')).toBe(contacts[0].id); + expect(contactForm.props('additionalCreateParams')).toMatchObject({ + groupId: 'gid://gitlab/Group/26', + }); + }); + }); + + describe('in create mode', () => { + it('should render contact form with correct props', () => { + mountComponent(); + + const contactForm = findContactForm(); + expect(contactForm.props('fields')).toHaveLength(5); + expect(contactForm.props('title')).toBe('New contact'); + expect(contactForm.props('successMessage')).toBe('Contact has been added.'); + expect(contactForm.props('mutation')).toBe(createContactMutation); + expect(contactForm.props('getQuery')).toMatchObject({ + query: getGroupContactsQuery, + variables: { groupFullPath: 'flightjs' }, + }); + expect(contactForm.props('getQueryNodePath')).toBe('group.contacts'); + expect(contactForm.props('existingId')).toBeNull(); + expect(contactForm.props('additionalCreateParams')).toMatchObject({ + groupId: 'gid://gitlab/Group/26', + }); + }); + }); +}); diff --git a/spec/frontend/crm/contacts_root_spec.js b/spec/frontend/crm/contacts_root_spec.js index b30349305a3..b02d94e9cb1 100644 --- a/spec/frontend/crm/contacts_root_spec.js +++ b/spec/frontend/crm/contacts_root_spec.js @@ -5,11 +5,9 @@ import VueRouter from 'vue-router'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import ContactsRoot from '~/crm/components/contacts_root.vue'; -import ContactForm from '~/crm/components/contact_form.vue'; -import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql'; -import { NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from '~/crm/constants'; -import routes from '~/crm/routes'; +import ContactsRoot from '~/crm/contacts/components/contacts_root.vue'; +import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql'; +import routes from '~/crm/contacts/routes'; import { getGroupContactsQueryResponse } from './mock_data'; describe('Customer relations contacts root app', () => { @@ -23,8 +21,6 @@ describe('Customer relations contacts root app', () => { const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName }); const findIssuesLinks = () => wrapper.findAllByTestId('issues-link'); const findNewContactButton = () => wrapper.findByTestId('new-contact-button'); - const findEditContactButton = () => wrapper.findByTestId('edit-contact-button'); - const findContactForm = () => wrapper.findComponent(ContactForm); const findError = () => wrapper.findComponent(GlAlert); const successQueryHandler = jest.fn().mockResolvedValue(getGroupContactsQueryResponse); @@ -40,8 +36,8 @@ describe('Customer relations contacts root app', () => { router, provide: { groupFullPath: 'flightjs', - groupIssuesPath: '/issues', groupId: 26, + groupIssuesPath: '/issues', canAdminCrmContact, }, apolloProvider: fakeApollo, @@ -82,71 +78,6 @@ describe('Customer relations contacts root app', () => { }); }); - describe('contact form', () => { - it('should not exist by default', async () => { - mountComponent(); - await waitForPromises(); - - expect(findContactForm().exists()).toBe(false); - }); - - it('should exist when user clicks new contact button', async () => { - mountComponent(); - - findNewContactButton().vm.$emit('click'); - await waitForPromises(); - - expect(findContactForm().exists()).toBe(true); - }); - - it('should exist when user navigates directly to `new` route', async () => { - router.replace({ name: NEW_ROUTE_NAME }); - mountComponent(); - await waitForPromises(); - - expect(findContactForm().exists()).toBe(true); - }); - - it('should exist when user clicks edit contact button', async () => { - mountComponent({ mountFunction: mountExtended }); - await waitForPromises(); - - findEditContactButton().vm.$emit('click'); - await waitForPromises(); - - expect(findContactForm().exists()).toBe(true); - }); - - it('should exist when user navigates directly to `edit` route', async () => { - router.replace({ name: EDIT_ROUTE_NAME, params: { id: 16 } }); - mountComponent(); - await waitForPromises(); - - expect(findContactForm().exists()).toBe(true); - }); - - it('should not exist when new form emits close', async () => { - router.replace({ name: NEW_ROUTE_NAME }); - mountComponent(); - - findContactForm().vm.$emit('close'); - await waitForPromises(); - - expect(findContactForm().exists()).toBe(false); - }); - - it('should not exist when edit form emits close', async () => { - router.replace({ name: EDIT_ROUTE_NAME, params: { id: 16 } }); - mountComponent(); - await waitForPromises(); - - findContactForm().vm.$emit('close'); - await waitForPromises(); - - expect(findContactForm().exists()).toBe(false); - }); - }); - describe('error', () => { it('should exist on reject', async () => { mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') }); diff --git a/spec/frontend/crm/form_spec.js b/spec/frontend/crm/form_spec.js index 0e3abc05c37..5c349b24ea1 100644 --- a/spec/frontend/crm/form_spec.js +++ b/spec/frontend/crm/form_spec.js @@ -6,12 +6,12 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import Form from '~/crm/components/form.vue'; -import routes from '~/crm/routes'; -import createContactMutation from '~/crm/components/queries/create_contact.mutation.graphql'; -import updateContactMutation from '~/crm/components/queries/update_contact.mutation.graphql'; -import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql'; -import createOrganizationMutation from '~/crm/components/queries/create_organization.mutation.graphql'; -import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql'; +import routes from '~/crm/contacts/routes'; +import createContactMutation from '~/crm/contacts/components/graphql/create_contact.mutation.graphql'; +import updateContactMutation from '~/crm/contacts/components/graphql/update_contact.mutation.graphql'; +import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql'; +import createOrganizationMutation from '~/crm/organizations/components/graphql/create_organization.mutation.graphql'; +import getGroupOrganizationsQuery from '~/crm/organizations/components/graphql/get_group_organizations.query.graphql'; import { createContactMutationErrorResponse, createContactMutationResponse, @@ -101,6 +101,11 @@ describe('Reusable form component', () => { { name: 'phone', label: 'Phone' }, { name: 'description', label: 'Description' }, ], + getQuery: { + query: getGroupContactsQuery, + variables: { groupFullPath: 'flightjs' }, + }, + getQueryNodePath: 'group.contacts', ...propsData, }); }; @@ -108,13 +113,8 @@ describe('Reusable form component', () => { const mountContactCreate = () => { const propsData = { title: 'New contact', - successMessage: 'Contact has been added', + successMessage: 'Contact has been added.', buttonLabel: 'Create contact', - getQuery: { - query: getGroupContactsQuery, - variables: { groupFullPath: 'flightjs' }, - }, - getQueryNodePath: 'group.contacts', mutation: createContactMutation, additionalCreateParams: { groupId: 'gid://gitlab/Group/26' }, }; @@ -124,14 +124,9 @@ describe('Reusable form component', () => { const mountContactUpdate = () => { const propsData = { title: 'Edit contact', - successMessage: 'Contact has been updated', + successMessage: 'Contact has been updated.', mutation: updateContactMutation, - existingModel: { - id: 'gid://gitlab/CustomerRelations::Contact/12', - firstName: 'First', - lastName: 'Last', - email: 'email@example.com', - }, + existingId: 'gid://gitlab/CustomerRelations::Contact/12', }; mountContact({ propsData }); }; @@ -143,6 +138,11 @@ describe('Reusable form component', () => { { name: 'defaultRate', label: 'Default rate', input: { type: 'number', step: '0.01' } }, { name: 'description', label: 'Description' }, ], + getQuery: { + query: getGroupOrganizationsQuery, + variables: { groupFullPath: 'flightjs' }, + }, + getQueryNodePath: 'group.organizations', ...propsData, }); }; @@ -150,13 +150,8 @@ describe('Reusable form component', () => { const mountOrganizationCreate = () => { const propsData = { title: 'New organization', - successMessage: 'Organization has been added', + successMessage: 'Organization has been added.', buttonLabel: 'Create organization', - getQuery: { - query: getGroupOrganizationsQuery, - variables: { groupFullPath: 'flightjs' }, - }, - getQueryNodePath: 'group.organizations', mutation: createOrganizationMutation, additionalCreateParams: { groupId: 'gid://gitlab/Group/26' }, }; @@ -167,17 +162,17 @@ describe('Reusable form component', () => { [FORM_CREATE_CONTACT]: { mountFunction: mountContactCreate, mutationErrorResponse: createContactMutationErrorResponse, - toastMessage: 'Contact has been added', + toastMessage: 'Contact has been added.', }, [FORM_UPDATE_CONTACT]: { mountFunction: mountContactUpdate, mutationErrorResponse: updateContactMutationErrorResponse, - toastMessage: 'Contact has been updated', + toastMessage: 'Contact has been updated.', }, [FORM_CREATE_ORG]: { mountFunction: mountOrganizationCreate, mutationErrorResponse: createOrganizationMutationErrorResponse, - toastMessage: 'Organization has been added', + toastMessage: 'Organization has been added.', }, }; const asTestParams = (...keys) => keys.map((name) => [name, forms[name]]); diff --git a/spec/frontend/crm/mock_data.js b/spec/frontend/crm/mock_data.js index e351e101b29..35bc7fb69b4 100644 --- a/spec/frontend/crm/mock_data.js +++ b/spec/frontend/crm/mock_data.js @@ -157,3 +157,28 @@ export const createOrganizationMutationErrorResponse = { }, }, }; + +export const updateOrganizationMutationResponse = { + data: { + customerRelationsOrganizationUpdate: { + __typeName: 'CustomerRelationsOrganizationUpdatePayload', + organization: { + __typename: 'CustomerRelationsOrganization', + id: 'gid://gitlab/CustomerRelations::Organization/2', + name: 'A', + defaultRate: null, + description: null, + }, + errors: [], + }, + }, +}; + +export const updateOrganizationMutationErrorResponse = { + data: { + customerRelationsOrganizationUpdate: { + organization: null, + errors: ['Description is invalid.'], + }, + }, +}; diff --git a/spec/frontend/crm/new_organization_form_spec.js b/spec/frontend/crm/new_organization_form_spec.js deleted file mode 100644 index 0a7909774c9..00000000000 --- a/spec/frontend/crm/new_organization_form_spec.js +++ /dev/null @@ -1,109 +0,0 @@ -import { GlAlert } from '@gitlab/ui'; -import Vue from 'vue'; -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 NewOrganizationForm from '~/crm/components/new_organization_form.vue'; -import createOrganizationMutation from '~/crm/components/queries/create_organization.mutation.graphql'; -import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql'; -import { - createOrganizationMutationErrorResponse, - createOrganizationMutationResponse, - getGroupOrganizationsQueryResponse, -} from './mock_data'; - -describe('Customer relations organizations root app', () => { - Vue.use(VueApollo); - let wrapper; - let fakeApollo; - let queryHandler; - - const findCreateNewOrganizationButton = () => - wrapper.findByTestId('create-new-organization-button'); - const findCancelButton = () => wrapper.findByTestId('cancel-button'); - const findForm = () => wrapper.find('form'); - const findError = () => wrapper.findComponent(GlAlert); - - const mountComponent = () => { - fakeApollo = createMockApollo([[createOrganizationMutation, queryHandler]]); - fakeApollo.clients.defaultClient.cache.writeQuery({ - query: getGroupOrganizationsQuery, - variables: { groupFullPath: 'flightjs' }, - data: getGroupOrganizationsQueryResponse.data, - }); - wrapper = shallowMountExtended(NewOrganizationForm, { - provide: { groupId: 26, groupFullPath: 'flightjs' }, - apolloProvider: fakeApollo, - propsData: { drawerOpen: true }, - }); - }; - - beforeEach(() => { - queryHandler = jest.fn().mockResolvedValue(createOrganizationMutationResponse); - }); - - afterEach(() => { - wrapper.destroy(); - fakeApollo = null; - }); - - describe('Create new organization button', () => { - it('should be disabled by default', () => { - mountComponent(); - - expect(findCreateNewOrganizationButton().attributes('disabled')).toBeTruthy(); - }); - - it('should not be disabled when first, last and email have values', async () => { - mountComponent(); - - wrapper.find('#organization-name').vm.$emit('input', 'A'); - await waitForPromises(); - - expect(findCreateNewOrganizationButton().attributes('disabled')).toBeFalsy(); - }); - }); - - it("should emit 'close' when cancel button is clicked", () => { - mountComponent(); - - findCancelButton().vm.$emit('click'); - - expect(wrapper.emitted().close).toBeTruthy(); - }); - - describe('when query is successful', () => { - it("should emit 'close'", async () => { - mountComponent(); - - findForm().trigger('submit'); - await waitForPromises(); - - expect(wrapper.emitted().close).toBeTruthy(); - }); - }); - - describe('when query fails', () => { - it('should show error on reject', async () => { - queryHandler = jest.fn().mockRejectedValue('ERROR'); - mountComponent(); - - findForm().trigger('submit'); - await waitForPromises(); - - expect(findError().exists()).toBe(true); - }); - - it('should show error on error response', async () => { - queryHandler = jest.fn().mockResolvedValue(createOrganizationMutationErrorResponse); - mountComponent(); - - findForm().trigger('submit'); - await waitForPromises(); - - expect(findError().exists()).toBe(true); - expect(findError().text()).toBe('create organization is invalid.'); - }); - }); -}); diff --git a/spec/frontend/crm/organization_form_wrapper_spec.js b/spec/frontend/crm/organization_form_wrapper_spec.js new file mode 100644 index 00000000000..1a5a7c6ca5d --- /dev/null +++ b/spec/frontend/crm/organization_form_wrapper_spec.js @@ -0,0 +1,88 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import OrganizationFormWrapper from '~/crm/organizations/components/organization_form_wrapper.vue'; +import OrganizationForm from '~/crm/components/form.vue'; +import getGroupOrganizationsQuery from '~/crm/organizations/components/graphql/get_group_organizations.query.graphql'; +import createOrganizationMutation from '~/crm/organizations/components/graphql/create_organization.mutation.graphql'; +import updateOrganizationMutation from '~/crm/organizations/components/graphql/update_organization.mutation.graphql'; + +describe('Customer relations organization form wrapper', () => { + let wrapper; + + const findOrganizationForm = () => wrapper.findComponent(OrganizationForm); + + const $apollo = { + queries: { + organizations: { + loading: false, + }, + }, + }; + const $route = { + params: { + id: 7, + }, + }; + const organizations = [{ id: 'gid://gitlab/CustomerRelations::Organization/7' }]; + + const mountComponent = ({ isEditMode = false } = {}) => { + wrapper = shallowMountExtended(OrganizationFormWrapper, { + propsData: { + isEditMode, + }, + provide: { + groupFullPath: 'flightjs', + groupId: 26, + }, + mocks: { + $apollo, + $route, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('in edit mode', () => { + it('should render organization form with correct props', () => { + mountComponent({ isEditMode: true }); + + const organizationForm = findOrganizationForm(); + expect(organizationForm.props('fields')).toHaveLength(3); + expect(organizationForm.props('title')).toBe('Edit organization'); + expect(organizationForm.props('successMessage')).toBe('Organization has been updated.'); + expect(organizationForm.props('mutation')).toBe(updateOrganizationMutation); + expect(organizationForm.props('getQuery')).toMatchObject({ + query: getGroupOrganizationsQuery, + variables: { groupFullPath: 'flightjs' }, + }); + expect(organizationForm.props('getQueryNodePath')).toBe('group.organizations'); + expect(organizationForm.props('existingId')).toBe(organizations[0].id); + expect(organizationForm.props('additionalCreateParams')).toMatchObject({ + groupId: 'gid://gitlab/Group/26', + }); + }); + }); + + describe('in create mode', () => { + it('should render organization form with correct props', () => { + mountComponent(); + + const organizationForm = findOrganizationForm(); + expect(organizationForm.props('fields')).toHaveLength(3); + expect(organizationForm.props('title')).toBe('New organization'); + expect(organizationForm.props('successMessage')).toBe('Organization has been added.'); + expect(organizationForm.props('mutation')).toBe(createOrganizationMutation); + expect(organizationForm.props('getQuery')).toMatchObject({ + query: getGroupOrganizationsQuery, + variables: { groupFullPath: 'flightjs' }, + }); + expect(organizationForm.props('getQueryNodePath')).toBe('group.organizations'); + expect(organizationForm.props('existingId')).toBeNull(); + expect(organizationForm.props('additionalCreateParams')).toMatchObject({ + groupId: 'gid://gitlab/Group/26', + }); + }); + }); +}); diff --git a/spec/frontend/crm/organizations_root_spec.js b/spec/frontend/crm/organizations_root_spec.js index aef417964f4..231208d938e 100644 --- a/spec/frontend/crm/organizations_root_spec.js +++ b/spec/frontend/crm/organizations_root_spec.js @@ -5,11 +5,9 @@ import VueRouter from 'vue-router'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import OrganizationsRoot from '~/crm/components/organizations_root.vue'; -import NewOrganizationForm from '~/crm/components/new_organization_form.vue'; -import { NEW_ROUTE_NAME } from '~/crm/constants'; -import routes from '~/crm/routes'; -import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql'; +import OrganizationsRoot from '~/crm/organizations/components/organizations_root.vue'; +import routes from '~/crm/organizations/routes'; +import getGroupOrganizationsQuery from '~/crm/organizations/components/graphql/get_group_organizations.query.graphql'; import { getGroupOrganizationsQueryResponse } from './mock_data'; describe('Customer relations organizations root app', () => { @@ -23,7 +21,6 @@ describe('Customer relations organizations root app', () => { const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName }); const findIssuesLinks = () => wrapper.findAllByTestId('issues-link'); const findNewOrganizationButton = () => wrapper.findByTestId('new-organization-button'); - const findNewOrganizationForm = () => wrapper.findComponent(NewOrganizationForm); const findError = () => wrapper.findComponent(GlAlert); const successQueryHandler = jest.fn().mockResolvedValue(getGroupOrganizationsQueryResponse); @@ -37,7 +34,11 @@ describe('Customer relations organizations root app', () => { fakeApollo = createMockApollo([[getGroupOrganizationsQuery, queryHandler]]); wrapper = mountFunction(OrganizationsRoot, { router, - provide: { canAdminCrmOrganization, groupFullPath: 'flightjs', groupIssuesPath: '/issues' }, + provide: { + canAdminCrmOrganization, + groupFullPath: 'flightjs', + groupIssuesPath: '/issues', + }, apolloProvider: fakeApollo, }); }; @@ -76,42 +77,6 @@ describe('Customer relations organizations root app', () => { }); }); - describe('new organization form', () => { - it('should not exist by default', async () => { - mountComponent(); - await waitForPromises(); - - expect(findNewOrganizationForm().exists()).toBe(false); - }); - - it('should exist when user clicks new contact button', async () => { - mountComponent(); - - findNewOrganizationButton().vm.$emit('click'); - await waitForPromises(); - - expect(findNewOrganizationForm().exists()).toBe(true); - }); - - it('should exist when user navigates directly to /new', async () => { - router.replace({ name: NEW_ROUTE_NAME }); - mountComponent(); - await waitForPromises(); - - expect(findNewOrganizationForm().exists()).toBe(true); - }); - - it('should not exist when form emits close', async () => { - router.replace({ name: NEW_ROUTE_NAME }); - mountComponent(); - - findNewOrganizationForm().vm.$emit('close'); - await waitForPromises(); - - expect(findNewOrganizationForm().exists()).toBe(false); - }); - }); - it('should render error message on reject', async () => { mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') }); await waitForPromises(); diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap index 4ecf82a4714..402e55347af 100644 --- a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap +++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap @@ -5,16 +5,19 @@ exports[`Design note component should match the snapshot 1`] = ` class="design-note note-form" id="note_123" > - <user-avatar-link-stub - imgalt="foo-bar" - imgcssclasses="" - imgsize="40" - imgsrc="" - linkhref="" - tooltipplacement="top" - tooltiptext="" - username="" - /> + <gl-avatar-link-stub + class="gl-float-left gl-mr-3" + href="https://gitlab.com/user" + > + <gl-avatar-stub + alt="avatar" + entityid="0" + entityname="foo-bar" + shape="circle" + size="32" + src="https://gitlab.com/avatar" + /> + </gl-avatar-link-stub> <div class="gl-display-flex gl-justify-content-space-between" @@ -22,8 +25,10 @@ exports[`Design note component should match the snapshot 1`] = ` <div> <gl-link-stub class="js-user-link" + data-testid="user-link" data-user-id="1" data-username="foo-bar" + href="https://gitlab.com/user" > <span class="note-header-author-name gl-font-weight-bold" @@ -69,8 +74,9 @@ exports[`Design note component should match the snapshot 1`] = ` </div> <div - class="note-text js-note-text md" + class="note-text md" data-qa-selector="note_content" + data-testid="note-text" /> </timeline-entry-item-stub> diff --git a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js index bbf2190ad47..77935fbde11 100644 --- a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js @@ -31,7 +31,6 @@ describe('Design discussions component', () => { const findReplyForm = () => wrapper.find(DesignReplyForm); const findRepliesWidget = () => wrapper.find(ToggleRepliesWidget); const findResolveButton = () => wrapper.find('[data-testid="resolve-button"]'); - const findResolveIcon = () => wrapper.find('[data-testid="resolve-icon"]'); const findResolvedMessage = () => wrapper.find('[data-testid="resolved-message"]'); const findResolveLoadingIcon = () => wrapper.find(GlLoadingIcon); const findResolveCheckbox = () => wrapper.find('[data-testid="resolve-checkbox"]'); @@ -117,7 +116,7 @@ describe('Design discussions component', () => { }); it('does not render an icon to resolve a thread', () => { - expect(findResolveIcon().exists()).toBe(false); + expect(findResolveButton().exists()).toBe(false); }); it('does not render a checkbox in reply form', async () => { @@ -147,7 +146,7 @@ describe('Design discussions component', () => { }); it('renders a correct icon to resolve a thread', () => { - expect(findResolveIcon().props('name')).toBe('check-circle'); + expect(findResolveButton().props('icon')).toBe('check-circle'); }); it('renders a checkbox with Resolve thread text in reply form', async () => { @@ -203,7 +202,7 @@ describe('Design discussions component', () => { }); it('renders a correct icon to resolve a thread', () => { - expect(findResolveIcon().props('name')).toBe('check-circle-filled'); + expect(findResolveButton().props('icon')).toBe('check-circle-filled'); }); it('emit todo:toggle when discussion is resolved', async () => { diff --git a/spec/frontend/design_management/components/design_notes/design_note_spec.js b/spec/frontend/design_management/components/design_notes/design_note_spec.js index 35fd1273270..1f84fde9f7f 100644 --- a/spec/frontend/design_management/components/design_notes/design_note_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_note_spec.js @@ -1,10 +1,10 @@ -import { shallowMount } from '@vue/test-utils'; import { ApolloMutation } from 'vue-apollo'; import { nextTick } from 'vue'; +import { GlAvatar, GlAvatarLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import DesignNote from '~/design_management/components/design_notes/design_note.vue'; import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; const scrollIntoViewMock = jest.fn(); const note = { @@ -12,6 +12,8 @@ const note = { author: { id: 'gid://gitlab/User/1', username: 'foo-bar', + avatarUrl: 'https://gitlab.com/avatar', + webUrl: 'https://gitlab.com/user', }, body: 'test', userPermissions: { @@ -30,14 +32,15 @@ const mutate = jest.fn().mockResolvedValue({ data: { updateNote: {} } }); describe('Design note component', () => { let wrapper; - const findUserAvatar = () => wrapper.find(UserAvatarLink); - const findUserLink = () => wrapper.find('.js-user-link'); - const findReplyForm = () => wrapper.find(DesignReplyForm); - const findEditButton = () => wrapper.find('.js-note-edit'); - const findNoteContent = () => wrapper.find('.js-note-text'); + const findUserAvatar = () => wrapper.findComponent(GlAvatar); + const findUserAvatarLink = () => wrapper.findComponent(GlAvatarLink); + const findUserLink = () => wrapper.findByTestId('user-link'); + const findReplyForm = () => wrapper.findComponent(DesignReplyForm); + const findEditButton = () => wrapper.findByTestId('note-edit'); + const findNoteContent = () => wrapper.findByTestId('note-text'); function createComponent(props = {}, data = { isEditing: false }) { - wrapper = shallowMount(DesignNote, { + wrapper = shallowMountExtended(DesignNote, { propsData: { note: {}, ...props, @@ -71,12 +74,24 @@ describe('Design note component', () => { expect(wrapper.element).toMatchSnapshot(); }); - it('should render an author', () => { + it('should render avatar with correct props', () => { + createComponent({ + note, + }); + + expect(findUserAvatar().props()).toMatchObject({ + src: note.author.avatarUrl, + entityName: note.author.username, + }); + + expect(findUserAvatarLink().attributes('href')).toBe(note.author.webUrl); + }); + + it('should render author details', () => { createComponent({ note, }); - expect(findUserAvatar().exists()).toBe(true); expect(findUserLink().exists()).toBe(true); }); @@ -107,7 +122,7 @@ describe('Design note component', () => { }, }); - findEditButton().trigger('click'); + findEditButton().vm.$emit('click'); await nextTick(); expect(findReplyForm().exists()).toBe(true); diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index a240a41959f..87531e8b645 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -183,7 +183,7 @@ describe('Design management index page', () => { [moveDesignMutation, moveDesignHandler], ]; - fakeApollo = createMockApollo(requestHandlers); + fakeApollo = createMockApollo(requestHandlers, {}, { addTypename: true }); wrapper = shallowMount(Index, { apolloProvider: fakeApollo, router, diff --git a/spec/frontend/diffs/components/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js index d887029124f..eee17e118a0 100644 --- a/spec/frontend/diffs/components/commit_item_spec.js +++ b/spec/frontend/diffs/components/commit_item_spec.js @@ -11,7 +11,9 @@ jest.mock('~/user_popovers'); const TEST_AUTHOR_NAME = 'test'; const TEST_AUTHOR_EMAIL = 'test+test@gitlab.com'; const TEST_AUTHOR_GRAVATAR = `${TEST_HOST}/avatar/test?s=40`; -const TEST_SIGNATURE_HTML = '<a>Legit commit</a>'; +const TEST_SIGNATURE_HTML = `<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>`; const TEST_PIPELINE_STATUS_PATH = `${TEST_HOST}/pipeline/status`; describe('diffs/components/commit_item', () => { @@ -82,7 +84,7 @@ describe('diffs/components/commit_item', () => { const imgElement = avatarElement.find('img'); expect(avatarElement.attributes('href')).toBe(commit.author.web_url); - expect(imgElement.classes()).toContain('s40'); + expect(imgElement.classes()).toContain('s32'); expect(imgElement.attributes('alt')).toBe(commit.author.name); expect(imgElement.attributes('src')).toBe(commit.author.avatar_url); }); @@ -156,8 +158,9 @@ describe('diffs/components/commit_item', () => { it('renders signature html', () => { const actionsElement = getCommitActionsElement(); + const signatureElement = actionsElement.find('.gpg-status-box'); - expect(actionsElement.html()).toContain(TEST_SIGNATURE_HTML); + expect(signatureElement.html()).toBe(TEST_SIGNATURE_HTML); }); }); 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 0ccf996e220..fb9dc22ce25 100644 --- a/spec/frontend/diffs/components/diff_line_note_form_spec.js +++ b/spec/frontend/diffs/components/diff_line_note_form_spec.js @@ -4,7 +4,7 @@ import DiffLineNoteForm from '~/diffs/components/diff_line_note_form.vue'; import { createStore } from '~/mr_notes/stores'; import NoteForm from '~/notes/components/note_form.vue'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; -import { noteableDataMock } from '../../notes/mock_data'; +import { noteableDataMock } from 'jest/notes/mock_data'; import diffFileMockData from '../mock_data/diff_file'; jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal', () => { @@ -98,7 +98,7 @@ describe('DiffLineNoteForm', () => { }); describe('saveNoteForm', () => { - it('should call saveNote action with proper params', (done) => { + it('should call saveNote action with proper params', async () => { const saveDiffDiscussionSpy = jest .spyOn(wrapper.vm, 'saveDiffDiscussion') .mockReturnValue(Promise.resolve()); @@ -123,16 +123,11 @@ describe('DiffLineNoteForm', () => { lineRange, }; - wrapper.vm - .handleSaveNote('note body') - .then(() => { - expect(saveDiffDiscussionSpy).toHaveBeenCalledWith({ - note: 'note body', - formData, - }); - }) - .then(done) - .catch(done.fail); + await wrapper.vm.handleSaveNote('note body'); + expect(saveDiffDiscussionSpy).toHaveBeenCalledWith({ + note: 'note body', + formData, + }); }); }); }); diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index d6a2aa104cd..3b567fbc704 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -9,46 +9,7 @@ import { INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE, } from '~/diffs/constants'; -import { - setBaseConfig, - fetchDiffFilesBatch, - fetchDiffFilesMeta, - fetchCoverageFiles, - assignDiscussionsToDiff, - removeDiscussionsFromDiff, - startRenderDiffsQueue, - setInlineDiffViewType, - setParallelDiffViewType, - showCommentForm, - cancelCommentForm, - loadMoreLines, - scrollToLineIfNeededInline, - scrollToLineIfNeededParallel, - loadCollapsedDiff, - toggleFileDiscussions, - saveDiffDiscussion, - setHighlightedRow, - toggleTreeOpen, - scrollToFile, - setShowTreeList, - renderFileForDiscussionId, - setRenderTreeList, - setShowWhitespace, - setRenderIt, - receiveFullDiffError, - fetchFullDiff, - toggleFullDiff, - switchToFullDiffFromRenamedFile, - setFileCollapsedByUser, - setExpandedDiffLines, - setSuggestPopoverDismissed, - changeCurrentCommit, - moveToNeighboringCommit, - setCurrentDiffFileIdFromNote, - navigateToDiffFileIndex, - setFileByFile, - reviewFile, -} from '~/diffs/store/actions'; +import * as diffActions from '~/diffs/store/actions'; import * as types from '~/diffs/store/mutation_types'; import * as utils from '~/diffs/store/utils'; import * as treeWorkerUtils from '~/diffs/utils/tree_worker_utils'; @@ -62,6 +23,8 @@ import { diffMetadata } from '../mock_data/diff_metadata'; jest.mock('~/flash'); describe('DiffsStoreActions', () => { + let mock; + useLocalStorageSpy(); const originalMethods = { @@ -83,15 +46,20 @@ describe('DiffsStoreActions', () => { }); }); + beforeEach(() => { + mock = new MockAdapter(axios); + }); + afterEach(() => { ['requestAnimationFrame', 'requestIdleCallback'].forEach((method) => { global[method] = originalMethods[method]; }); createFlash.mockClear(); + mock.restore(); }); describe('setBaseConfig', () => { - it('should set given endpoint and project path', (done) => { + it('should set given endpoint and project path', () => { const endpoint = '/diffs/set/endpoint'; const endpointMetadata = '/diffs/set/endpoint/metadata'; const endpointBatch = '/diffs/set/endpoint/batch'; @@ -104,8 +72,8 @@ describe('DiffsStoreActions', () => { b: ['y', 'hash:a'], }; - testAction( - setBaseConfig, + return testAction( + diffActions.setBaseConfig, { endpoint, endpointBatch, @@ -153,23 +121,12 @@ describe('DiffsStoreActions', () => { }, ], [], - done, ); }); }); describe('fetchDiffFilesBatch', () => { - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - it('should fetch batch diff files', (done) => { + it('should fetch batch diff files', () => { const endpointBatch = '/fetch/diffs_batch'; const res1 = { diff_files: [{ file_hash: 'test' }], pagination: { total_pages: 7 } }; const res2 = { diff_files: [{ file_hash: 'test2' }], pagination: { total_pages: 7 } }; @@ -199,8 +156,8 @@ describe('DiffsStoreActions', () => { ) .reply(200, res2); - testAction( - fetchDiffFilesBatch, + return testAction( + diffActions.fetchDiffFilesBatch, {}, { endpointBatch, diffViewType: 'inline', diffFiles: [] }, [ @@ -216,7 +173,6 @@ describe('DiffsStoreActions', () => { { type: types.SET_BATCH_LOADING_STATE, payload: 'error' }, ], [{ type: 'startRenderDiffsQueue' }, { type: 'startRenderDiffsQueue' }], - done, ); }); @@ -229,13 +185,14 @@ describe('DiffsStoreActions', () => { ({ viewStyle, otherView }) => { const endpointBatch = '/fetch/diffs_batch'; - fetchDiffFilesBatch({ - commit: () => {}, - state: { - endpointBatch: `${endpointBatch}?view=${otherView}`, - diffViewType: viewStyle, - }, - }) + diffActions + .fetchDiffFilesBatch({ + commit: () => {}, + state: { + endpointBatch: `${endpointBatch}?view=${otherView}`, + diffViewType: viewStyle, + }, + }) .then(() => { expect(mock.history.get[0].url).toContain(`view=${viewStyle}`); expect(mock.history.get[0].url).not.toContain(`view=${otherView}`); @@ -248,19 +205,16 @@ describe('DiffsStoreActions', () => { describe('fetchDiffFilesMeta', () => { const endpointMetadata = '/fetch/diffs_metadata.json?view=inline'; const noFilesData = { ...diffMetadata }; - let mock; beforeEach(() => { - mock = new MockAdapter(axios); - delete noFilesData.diff_files; mock.onGet(endpointMetadata).reply(200, diffMetadata); }); - it('should fetch diff meta information', (done) => { - testAction( - fetchDiffFilesMeta, + it('should fetch diff meta information', () => { + return testAction( + diffActions.fetchDiffFilesMeta, {}, { endpointMetadata, diffViewType: 'inline' }, [ @@ -275,55 +229,41 @@ describe('DiffsStoreActions', () => { }, ], [], - () => { - mock.restore(); - done(); - }, ); }); }); describe('fetchCoverageFiles', () => { - let mock; const endpointCoverage = '/fetch'; - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => mock.restore()); - - it('should commit SET_COVERAGE_DATA with received response', (done) => { + it('should commit SET_COVERAGE_DATA with received response', () => { const data = { files: { 'app.js': { 1: 0, 2: 1 } } }; mock.onGet(endpointCoverage).reply(200, { data }); - testAction( - fetchCoverageFiles, + return testAction( + diffActions.fetchCoverageFiles, {}, { endpointCoverage }, [{ type: types.SET_COVERAGE_DATA, payload: { data } }], [], - done, ); }); - it('should show flash on API error', (done) => { + it('should show flash on API error', async () => { mock.onGet(endpointCoverage).reply(400); - testAction(fetchCoverageFiles, {}, { endpointCoverage }, [], [], () => { - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ - message: expect.stringMatching('Something went wrong'), - }); - done(); + await testAction(diffActions.fetchCoverageFiles, {}, { endpointCoverage }, [], []); + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringMatching('Something went wrong'), }); }); }); describe('setHighlightedRow', () => { it('should mark currently selected diff and set lineHash and fileHash of highlightedRow', () => { - testAction(setHighlightedRow, 'ABC_123', {}, [ + return testAction(diffActions.setHighlightedRow, 'ABC_123', {}, [ { type: types.SET_HIGHLIGHTED_ROW, payload: 'ABC_123' }, { type: types.SET_CURRENT_DIFF_FILE, payload: 'ABC' }, ]); @@ -335,7 +275,7 @@ describe('DiffsStoreActions', () => { window.location.hash = ''; }); - it('should merge discussions into diffs', (done) => { + it('should merge discussions into diffs', () => { window.location.hash = 'ABC_123'; const state = { @@ -397,8 +337,8 @@ describe('DiffsStoreActions', () => { const discussions = [singleDiscussion]; - testAction( - assignDiscussionsToDiff, + return testAction( + diffActions.assignDiscussionsToDiff, discussions, state, [ @@ -425,26 +365,24 @@ describe('DiffsStoreActions', () => { }, ], [], - done, ); }); - it('dispatches setCurrentDiffFileIdFromNote with note ID', (done) => { + it('dispatches setCurrentDiffFileIdFromNote with note ID', () => { window.location.hash = 'note_123'; - testAction( - assignDiscussionsToDiff, + return testAction( + diffActions.assignDiscussionsToDiff, [], { diffFiles: [] }, [], [{ type: 'setCurrentDiffFileIdFromNote', payload: '123' }], - done, ); }); }); describe('removeDiscussionsFromDiff', () => { - it('should remove discussions from diffs', (done) => { + it('should remove discussions from diffs', () => { const state = { diffFiles: [ { @@ -480,8 +418,8 @@ describe('DiffsStoreActions', () => { line_code: 'ABC_1_1', }; - testAction( - removeDiscussionsFromDiff, + return testAction( + diffActions.removeDiscussionsFromDiff, singleDiscussion, state, [ @@ -495,7 +433,6 @@ describe('DiffsStoreActions', () => { }, ], [], - done, ); }); }); @@ -528,7 +465,7 @@ describe('DiffsStoreActions', () => { }); }; - startRenderDiffsQueue({ state, commit: pseudoCommit }); + diffActions.startRenderDiffsQueue({ state, commit: pseudoCommit }); expect(state.diffFiles[0].renderIt).toBe(true); expect(state.diffFiles[1].renderIt).toBe(true); @@ -536,69 +473,61 @@ describe('DiffsStoreActions', () => { }); describe('setInlineDiffViewType', () => { - it('should set diff view type to inline and also set the cookie properly', (done) => { - testAction( - setInlineDiffViewType, + it('should set diff view type to inline and also set the cookie properly', async () => { + await testAction( + diffActions.setInlineDiffViewType, null, {}, [{ type: types.SET_DIFF_VIEW_TYPE, payload: INLINE_DIFF_VIEW_TYPE }], [], - () => { - expect(Cookies.get('diff_view')).toEqual(INLINE_DIFF_VIEW_TYPE); - done(); - }, ); + expect(Cookies.get('diff_view')).toEqual(INLINE_DIFF_VIEW_TYPE); }); }); describe('setParallelDiffViewType', () => { - it('should set diff view type to parallel and also set the cookie properly', (done) => { - testAction( - setParallelDiffViewType, + it('should set diff view type to parallel and also set the cookie properly', async () => { + await testAction( + diffActions.setParallelDiffViewType, null, {}, [{ type: types.SET_DIFF_VIEW_TYPE, payload: PARALLEL_DIFF_VIEW_TYPE }], [], - () => { - expect(Cookies.get(DIFF_VIEW_COOKIE_NAME)).toEqual(PARALLEL_DIFF_VIEW_TYPE); - done(); - }, ); + expect(Cookies.get(DIFF_VIEW_COOKIE_NAME)).toEqual(PARALLEL_DIFF_VIEW_TYPE); }); }); describe('showCommentForm', () => { - it('should call mutation to show comment form', (done) => { + it('should call mutation to show comment form', () => { const payload = { lineCode: 'lineCode', fileHash: 'hash' }; - testAction( - showCommentForm, + return testAction( + diffActions.showCommentForm, payload, {}, [{ type: types.TOGGLE_LINE_HAS_FORM, payload: { ...payload, hasForm: true } }], [], - done, ); }); }); describe('cancelCommentForm', () => { - it('should call mutation to cancel comment form', (done) => { + it('should call mutation to cancel comment form', () => { const payload = { lineCode: 'lineCode', fileHash: 'hash' }; - testAction( - cancelCommentForm, + return testAction( + diffActions.cancelCommentForm, payload, {}, [{ type: types.TOGGLE_LINE_HAS_FORM, payload: { ...payload, hasForm: false } }], [], - done, ); }); }); describe('loadMoreLines', () => { - it('should call mutation to show comment form', (done) => { + it('should call mutation to show comment form', () => { const endpoint = '/diffs/load/more/lines'; const params = { since: 6, to: 26 }; const lineNumbers = { oldLineNumber: 3, newLineNumber: 5 }; @@ -606,12 +535,11 @@ describe('DiffsStoreActions', () => { const isExpandDown = false; const nextLineNumbers = {}; const options = { endpoint, params, lineNumbers, fileHash, isExpandDown, nextLineNumbers }; - const mock = new MockAdapter(axios); const contextLines = { contextLines: [{ lineCode: 6 }] }; mock.onGet(endpoint).reply(200, contextLines); - testAction( - loadMoreLines, + return testAction( + diffActions.loadMoreLines, options, {}, [ @@ -621,31 +549,23 @@ describe('DiffsStoreActions', () => { }, ], [], - () => { - mock.restore(); - done(); - }, ); }); }); describe('loadCollapsedDiff', () => { const state = { showWhitespace: true }; - it('should fetch data and call mutation with response and the give parameter', (done) => { + it('should fetch data and call mutation with response and the give parameter', () => { const file = { hash: 123, load_collapsed_diff_url: '/load/collapsed/diff/url' }; const data = { hash: 123, parallelDiffLines: [{ lineCode: 1 }] }; - const mock = new MockAdapter(axios); const commit = jest.fn(); mock.onGet(file.loadCollapsedDiffUrl).reply(200, data); - loadCollapsedDiff({ commit, getters: { commitId: null }, state }, file) + return diffActions + .loadCollapsedDiff({ commit, getters: { commitId: null }, state }, file) .then(() => { expect(commit).toHaveBeenCalledWith(types.ADD_COLLAPSED_DIFFS, { file, data }); - - mock.restore(); - done(); - }) - .catch(done.fail); + }); }); it('should fetch data without commit ID', () => { @@ -656,7 +576,7 @@ describe('DiffsStoreActions', () => { jest.spyOn(axios, 'get').mockReturnValue(Promise.resolve({ data: {} })); - loadCollapsedDiff({ commit() {}, getters, state }, file); + diffActions.loadCollapsedDiff({ commit() {}, getters, state }, file); expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, { params: { commit_id: null, w: '0' }, @@ -671,7 +591,7 @@ describe('DiffsStoreActions', () => { jest.spyOn(axios, 'get').mockReturnValue(Promise.resolve({ data: {} })); - loadCollapsedDiff({ commit() {}, getters, state }, file); + diffActions.loadCollapsedDiff({ commit() {}, getters, state }, file); expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, { params: { commit_id: '123', w: '0' }, @@ -689,7 +609,7 @@ describe('DiffsStoreActions', () => { const dispatch = jest.fn(); - toggleFileDiscussions({ getters, dispatch }); + diffActions.toggleFileDiscussions({ getters, dispatch }); expect(dispatch).toHaveBeenCalledWith( 'collapseDiscussion', @@ -707,7 +627,7 @@ describe('DiffsStoreActions', () => { const dispatch = jest.fn(); - toggleFileDiscussions({ getters, dispatch }); + diffActions.toggleFileDiscussions({ getters, dispatch }); expect(dispatch).toHaveBeenCalledWith( 'expandDiscussion', @@ -725,7 +645,7 @@ describe('DiffsStoreActions', () => { const dispatch = jest.fn(); - toggleFileDiscussions({ getters, dispatch }); + diffActions.toggleFileDiscussions({ getters, dispatch }); expect(dispatch).toHaveBeenCalledWith( 'expandDiscussion', @@ -743,7 +663,7 @@ describe('DiffsStoreActions', () => { it('should not call handleLocationHash when there is not hash', () => { window.location.hash = ''; - scrollToLineIfNeededInline({}, lineMock); + diffActions.scrollToLineIfNeededInline({}, lineMock); expect(commonUtils.handleLocationHash).not.toHaveBeenCalled(); }); @@ -751,7 +671,7 @@ describe('DiffsStoreActions', () => { it('should not call handleLocationHash when the hash does not match any line', () => { window.location.hash = 'XYZ_456'; - scrollToLineIfNeededInline({}, lineMock); + diffActions.scrollToLineIfNeededInline({}, lineMock); expect(commonUtils.handleLocationHash).not.toHaveBeenCalled(); }); @@ -759,14 +679,14 @@ describe('DiffsStoreActions', () => { it('should call handleLocationHash only when the hash matches a line', () => { window.location.hash = 'ABC_123'; - scrollToLineIfNeededInline( + diffActions.scrollToLineIfNeededInline( {}, { lineCode: 'ABC_456', }, ); - scrollToLineIfNeededInline({}, lineMock); - scrollToLineIfNeededInline( + diffActions.scrollToLineIfNeededInline({}, lineMock); + diffActions.scrollToLineIfNeededInline( {}, { lineCode: 'XYZ_456', @@ -789,7 +709,7 @@ describe('DiffsStoreActions', () => { it('should not call handleLocationHash when there is not hash', () => { window.location.hash = ''; - scrollToLineIfNeededParallel({}, lineMock); + diffActions.scrollToLineIfNeededParallel({}, lineMock); expect(commonUtils.handleLocationHash).not.toHaveBeenCalled(); }); @@ -797,7 +717,7 @@ describe('DiffsStoreActions', () => { it('should not call handleLocationHash when the hash does not match any line', () => { window.location.hash = 'XYZ_456'; - scrollToLineIfNeededParallel({}, lineMock); + diffActions.scrollToLineIfNeededParallel({}, lineMock); expect(commonUtils.handleLocationHash).not.toHaveBeenCalled(); }); @@ -805,7 +725,7 @@ describe('DiffsStoreActions', () => { it('should call handleLocationHash only when the hash matches a line', () => { window.location.hash = 'ABC_123'; - scrollToLineIfNeededParallel( + diffActions.scrollToLineIfNeededParallel( {}, { left: null, @@ -814,8 +734,8 @@ describe('DiffsStoreActions', () => { }, }, ); - scrollToLineIfNeededParallel({}, lineMock); - scrollToLineIfNeededParallel( + diffActions.scrollToLineIfNeededParallel({}, lineMock); + diffActions.scrollToLineIfNeededParallel( {}, { left: null, @@ -831,7 +751,7 @@ describe('DiffsStoreActions', () => { }); describe('saveDiffDiscussion', () => { - it('dispatches actions', (done) => { + it('dispatches actions', () => { const commitId = 'something'; const formData = { diffFile: { ...mockDiffFile }, @@ -856,33 +776,29 @@ describe('DiffsStoreActions', () => { } }); - saveDiffDiscussion({ state, dispatch }, { note, formData }) - .then(() => { - expect(dispatch).toHaveBeenCalledTimes(5); - expect(dispatch).toHaveBeenNthCalledWith(1, 'saveNote', expect.any(Object), { - root: true, - }); + return diffActions.saveDiffDiscussion({ state, dispatch }, { note, formData }).then(() => { + expect(dispatch).toHaveBeenCalledTimes(5); + expect(dispatch).toHaveBeenNthCalledWith(1, 'saveNote', expect.any(Object), { + root: true, + }); - const postData = dispatch.mock.calls[0][1]; - expect(postData.data.note.commit_id).toBe(commitId); + const postData = dispatch.mock.calls[0][1]; + expect(postData.data.note.commit_id).toBe(commitId); - expect(dispatch).toHaveBeenNthCalledWith(2, 'updateDiscussion', 'test', { root: true }); - expect(dispatch).toHaveBeenNthCalledWith(3, 'assignDiscussionsToDiff', ['discussion']); - }) - .then(done) - .catch(done.fail); + expect(dispatch).toHaveBeenNthCalledWith(2, 'updateDiscussion', 'test', { root: true }); + expect(dispatch).toHaveBeenNthCalledWith(3, 'assignDiscussionsToDiff', ['discussion']); + }); }); }); describe('toggleTreeOpen', () => { - it('commits TOGGLE_FOLDER_OPEN', (done) => { - testAction( - toggleTreeOpen, + it('commits TOGGLE_FOLDER_OPEN', () => { + return testAction( + diffActions.toggleTreeOpen, 'path', {}, [{ type: types.TOGGLE_FOLDER_OPEN, payload: 'path' }], [], - done, ); }); }); @@ -904,7 +820,7 @@ describe('DiffsStoreActions', () => { }, }; - scrollToFile({ state, commit, getters }, { path: 'path' }); + diffActions.scrollToFile({ state, commit, getters }, { path: 'path' }); expect(document.location.hash).toBe('#test'); }); @@ -918,28 +834,27 @@ describe('DiffsStoreActions', () => { }, }; - scrollToFile({ state, commit, getters }, { path: 'path' }); + diffActions.scrollToFile({ state, commit, getters }, { path: 'path' }); expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, 'test'); }); }); describe('setShowTreeList', () => { - it('commits toggle', (done) => { - testAction( - setShowTreeList, + it('commits toggle', () => { + return testAction( + diffActions.setShowTreeList, { showTreeList: true }, {}, [{ type: types.SET_SHOW_TREE_LIST, payload: true }], [], - done, ); }); it('updates localStorage', () => { jest.spyOn(localStorage, 'setItem').mockImplementation(() => {}); - setShowTreeList({ commit() {} }, { showTreeList: true }); + diffActions.setShowTreeList({ commit() {} }, { showTreeList: true }); expect(localStorage.setItem).toHaveBeenCalledWith('mr_tree_show', true); }); @@ -947,7 +862,7 @@ describe('DiffsStoreActions', () => { it('does not update localStorage', () => { jest.spyOn(localStorage, 'setItem').mockImplementation(() => {}); - setShowTreeList({ commit() {} }, { showTreeList: true, saving: false }); + diffActions.setShowTreeList({ commit() {} }, { showTreeList: true, saving: false }); expect(localStorage.setItem).not.toHaveBeenCalled(); }); @@ -994,7 +909,7 @@ describe('DiffsStoreActions', () => { it('renders and expands file for the given discussion id', () => { const localState = state({ collapsed: true, renderIt: false }); - renderFileForDiscussionId({ rootState, state: localState, commit }, '123'); + diffActions.renderFileForDiscussionId({ rootState, state: localState, commit }, '123'); expect(commit).toHaveBeenCalledWith('RENDER_FILE', localState.diffFiles[0]); expect($emit).toHaveBeenCalledTimes(1); @@ -1004,7 +919,7 @@ describe('DiffsStoreActions', () => { it('jumps to discussion on already rendered and expanded file', () => { const localState = state({ collapsed: false, renderIt: true }); - renderFileForDiscussionId({ rootState, state: localState, commit }, '123'); + diffActions.renderFileForDiscussionId({ rootState, state: localState, commit }, '123'); expect(commit).not.toHaveBeenCalled(); expect($emit).toHaveBeenCalledTimes(1); @@ -1013,19 +928,18 @@ describe('DiffsStoreActions', () => { }); describe('setRenderTreeList', () => { - it('commits SET_RENDER_TREE_LIST', (done) => { - testAction( - setRenderTreeList, + it('commits SET_RENDER_TREE_LIST', () => { + return testAction( + diffActions.setRenderTreeList, { renderTreeList: true }, {}, [{ type: types.SET_RENDER_TREE_LIST, payload: true }], [], - done, ); }); it('sets localStorage', () => { - setRenderTreeList({ commit() {} }, { renderTreeList: true }); + diffActions.setRenderTreeList({ commit() {} }, { renderTreeList: true }); expect(localStorage.setItem).toHaveBeenCalledWith('mr_diff_tree_list', true); }); @@ -1034,11 +948,9 @@ describe('DiffsStoreActions', () => { describe('setShowWhitespace', () => { const endpointUpdateUser = 'user/prefs'; let putSpy; - let mock; let gon; beforeEach(() => { - mock = new MockAdapter(axios); putSpy = jest.spyOn(axios, 'put'); gon = window.gon; @@ -1047,25 +959,23 @@ describe('DiffsStoreActions', () => { }); afterEach(() => { - mock.restore(); window.gon = gon; }); - it('commits SET_SHOW_WHITESPACE', (done) => { - testAction( - setShowWhitespace, + it('commits SET_SHOW_WHITESPACE', () => { + return testAction( + diffActions.setShowWhitespace, { showWhitespace: true, updateDatabase: false }, {}, [{ type: types.SET_SHOW_WHITESPACE, payload: true }], [], - done, ); }); it('saves to the database when the user is logged in', async () => { window.gon = { current_user_id: 12345 }; - await setShowWhitespace( + await diffActions.setShowWhitespace( { state: { endpointUpdateUser }, commit() {} }, { showWhitespace: true, updateDatabase: true }, ); @@ -1076,7 +986,7 @@ describe('DiffsStoreActions', () => { it('does not try to save to the API if the user is not logged in', async () => { window.gon = {}; - await setShowWhitespace( + await diffActions.setShowWhitespace( { state: { endpointUpdateUser }, commit() {} }, { showWhitespace: true, updateDatabase: true }, ); @@ -1085,7 +995,7 @@ describe('DiffsStoreActions', () => { }); it('emits eventHub event', async () => { - await setShowWhitespace( + await diffActions.setShowWhitespace( { state: {}, commit() {} }, { showWhitespace: true, updateDatabase: false }, ); @@ -1095,53 +1005,47 @@ describe('DiffsStoreActions', () => { }); describe('setRenderIt', () => { - it('commits RENDER_FILE', (done) => { - testAction(setRenderIt, 'file', {}, [{ type: types.RENDER_FILE, payload: 'file' }], [], done); + it('commits RENDER_FILE', () => { + return testAction( + diffActions.setRenderIt, + 'file', + {}, + [{ type: types.RENDER_FILE, payload: 'file' }], + [], + ); }); }); describe('receiveFullDiffError', () => { - it('updates state with the file that did not load', (done) => { - testAction( - receiveFullDiffError, + it('updates state with the file that did not load', () => { + return testAction( + diffActions.receiveFullDiffError, 'file', {}, [{ type: types.RECEIVE_FULL_DIFF_ERROR, payload: 'file' }], [], - done, ); }); }); describe('fetchFullDiff', () => { - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - describe('success', () => { beforeEach(() => { mock.onGet(`${TEST_HOST}/context`).replyOnce(200, ['test']); }); - it('commits the success and dispatches an action to expand the new lines', (done) => { + it('commits the success and dispatches an action to expand the new lines', () => { const file = { context_lines_path: `${TEST_HOST}/context`, file_path: 'test', file_hash: 'test', }; - testAction( - fetchFullDiff, + return testAction( + diffActions.fetchFullDiff, file, null, [{ type: types.RECEIVE_FULL_DIFF_SUCCESS, payload: { filePath: 'test' } }], [{ type: 'setExpandedDiffLines', payload: { file, data: ['test'] } }], - done, ); }); }); @@ -1151,14 +1055,13 @@ describe('DiffsStoreActions', () => { mock.onGet(`${TEST_HOST}/context`).replyOnce(500); }); - it('dispatches receiveFullDiffError', (done) => { - testAction( - fetchFullDiff, + it('dispatches receiveFullDiffError', () => { + return testAction( + diffActions.fetchFullDiff, { context_lines_path: `${TEST_HOST}/context`, file_path: 'test', file_hash: 'test' }, null, [], [{ type: 'receiveFullDiffError', payload: 'test' }], - done, ); }); }); @@ -1173,14 +1076,13 @@ describe('DiffsStoreActions', () => { }; }); - it('dispatches fetchFullDiff when file is not expanded', (done) => { - testAction( - toggleFullDiff, + it('dispatches fetchFullDiff when file is not expanded', () => { + return testAction( + diffActions.toggleFullDiff, 'test', state, [{ type: types.REQUEST_FULL_DIFF, payload: 'test' }], [{ type: 'fetchFullDiff', payload: state.diffFiles[0] }], - done, ); }); }); @@ -1202,16 +1104,13 @@ describe('DiffsStoreActions', () => { }; const testData = [{ rich_text: 'test' }, { rich_text: 'file2' }]; let renamedFile; - let mock; beforeEach(() => { - mock = new MockAdapter(axios); jest.spyOn(utils, 'prepareLineForRenamedFile').mockImplementation(() => preparedLine); }); afterEach(() => { renamedFile = null; - mock.restore(); }); describe('success', () => { @@ -1228,7 +1127,7 @@ describe('DiffsStoreActions', () => { 'performs the correct mutations and starts a render queue for view type $diffViewType', ({ diffViewType }) => { return testAction( - switchToFullDiffFromRenamedFile, + diffActions.switchToFullDiffFromRenamedFile, { diffFile: renamedFile }, { diffViewType }, [ @@ -1249,9 +1148,9 @@ describe('DiffsStoreActions', () => { }); describe('setFileUserCollapsed', () => { - it('commits SET_FILE_COLLAPSED', (done) => { - testAction( - setFileCollapsedByUser, + it('commits SET_FILE_COLLAPSED', () => { + return testAction( + diffActions.setFileCollapsedByUser, { filePath: 'test', collapsed: true }, null, [ @@ -1261,7 +1160,6 @@ describe('DiffsStoreActions', () => { }, ], [], - done, ); }); }); @@ -1273,11 +1171,11 @@ describe('DiffsStoreActions', () => { }); }); - it('commits SET_CURRENT_VIEW_DIFF_FILE_LINES when lines less than MAX_RENDERING_DIFF_LINES', (done) => { + it('commits SET_CURRENT_VIEW_DIFF_FILE_LINES when lines less than MAX_RENDERING_DIFF_LINES', () => { utils.convertExpandLines.mockImplementation(() => ['test']); - testAction( - setExpandedDiffLines, + return testAction( + diffActions.setExpandedDiffLines, { file: { file_path: 'path' }, data: [] }, { diffViewType: 'inline' }, [ @@ -1287,16 +1185,15 @@ describe('DiffsStoreActions', () => { }, ], [], - done, ); }); - it('commits ADD_CURRENT_VIEW_DIFF_FILE_LINES when lines more than MAX_RENDERING_DIFF_LINES', (done) => { + it('commits ADD_CURRENT_VIEW_DIFF_FILE_LINES when lines more than MAX_RENDERING_DIFF_LINES', () => { const lines = new Array(501).fill().map((_, i) => `line-${i}`); utils.convertExpandLines.mockReturnValue(lines); - testAction( - setExpandedDiffLines, + return testAction( + diffActions.setExpandedDiffLines, { file: { file_path: 'path' }, data: [] }, { diffViewType: 'inline' }, [ @@ -1312,41 +1209,34 @@ describe('DiffsStoreActions', () => { { type: 'TOGGLE_DIFF_FILE_RENDERING_MORE', payload: 'path' }, ], [], - done, ); }); }); describe('setSuggestPopoverDismissed', () => { - it('commits SET_SHOW_SUGGEST_POPOVER', (done) => { + it('commits SET_SHOW_SUGGEST_POPOVER', async () => { const state = { dismissEndpoint: `${TEST_HOST}/-/user_callouts` }; - const mock = new MockAdapter(axios); mock.onPost(state.dismissEndpoint).reply(200, {}); jest.spyOn(axios, 'post'); - testAction( - setSuggestPopoverDismissed, + await testAction( + diffActions.setSuggestPopoverDismissed, null, state, [{ type: types.SET_SHOW_SUGGEST_POPOVER }], [], - () => { - expect(axios.post).toHaveBeenCalledWith(state.dismissEndpoint, { - feature_name: 'suggest_popover_dismissed', - }); - - mock.restore(); - done(); - }, ); + expect(axios.post).toHaveBeenCalledWith(state.dismissEndpoint, { + feature_name: 'suggest_popover_dismissed', + }); }); }); describe('changeCurrentCommit', () => { it('commits the new commit information and re-requests the diff metadata for the commit', () => { return testAction( - changeCurrentCommit, + diffActions.changeCurrentCommit, { commitId: 'NEW' }, { commit: { @@ -1384,7 +1274,7 @@ describe('DiffsStoreActions', () => { ({ commitId, commit, msg }) => { const err = new Error(msg); const actionReturn = testAction( - changeCurrentCommit, + diffActions.changeCurrentCommit, { commitId }, { endpoint: 'URL/OLD', @@ -1410,7 +1300,7 @@ describe('DiffsStoreActions', () => { 'for the direction "$direction", dispatches the action to move to the SHA "$expected"', ({ direction, expected, currentCommit }) => { return testAction( - moveToNeighboringCommit, + diffActions.moveToNeighboringCommit, { direction }, { commit: currentCommit }, [], @@ -1431,7 +1321,7 @@ describe('DiffsStoreActions', () => { 'given `{ "isloading": $diffsAreLoading, "commit": $currentCommit }` in state, no actions are dispatched', ({ direction, diffsAreLoading, currentCommit }) => { return testAction( - moveToNeighboringCommit, + diffActions.moveToNeighboringCommit, { direction }, { commit: currentCommit, isLoading: diffsAreLoading }, [], @@ -1450,7 +1340,7 @@ describe('DiffsStoreActions', () => { notesById: { 1: { discussion_id: '2' } }, }; - setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); + diffActions.setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, '123'); }); @@ -1463,7 +1353,7 @@ describe('DiffsStoreActions', () => { notesById: { 1: { discussion_id: '2' } }, }; - setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); + diffActions.setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); expect(commit).not.toHaveBeenCalled(); }); @@ -1476,21 +1366,20 @@ describe('DiffsStoreActions', () => { notesById: { 1: { discussion_id: '2' } }, }; - setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); + diffActions.setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); expect(commit).not.toHaveBeenCalled(); }); }); describe('navigateToDiffFileIndex', () => { - it('commits SET_CURRENT_DIFF_FILE', (done) => { - testAction( - navigateToDiffFileIndex, + it('commits SET_CURRENT_DIFF_FILE', () => { + return testAction( + diffActions.navigateToDiffFileIndex, 0, { diffFiles: [{ file_hash: '123' }] }, [{ type: types.SET_CURRENT_DIFF_FILE, payload: '123' }], [], - done, ); }); }); @@ -1498,19 +1387,13 @@ describe('DiffsStoreActions', () => { describe('setFileByFile', () => { const updateUserEndpoint = 'user/prefs'; let putSpy; - let mock; beforeEach(() => { - mock = new MockAdapter(axios); putSpy = jest.spyOn(axios, 'put'); mock.onPut(updateUserEndpoint).reply(200, {}); }); - afterEach(() => { - mock.restore(); - }); - it.each` value ${true} @@ -1519,7 +1402,7 @@ describe('DiffsStoreActions', () => { 'commits SET_FILE_BY_FILE and persists the File-by-File user preference with the new value $value', async ({ value }) => { await testAction( - setFileByFile, + diffActions.setFileByFile, { fileByFile: value }, { viewDiffsFileByFile: null, @@ -1551,7 +1434,7 @@ describe('DiffsStoreActions', () => { const commitSpy = jest.fn(); const getterSpy = jest.fn().mockReturnValue([]); - reviewFile( + diffActions.reviewFile( { commit: commitSpy, getters: { diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js index 55c0141552d..03bcaab0d2b 100644 --- a/spec/frontend/diffs/store/utils_spec.js +++ b/spec/frontend/diffs/store/utils_spec.js @@ -13,7 +13,7 @@ import { } from '~/diffs/constants'; import * as utils from '~/diffs/store/utils'; import { MERGE_REQUEST_NOTEABLE_TYPE } from '~/notes/constants'; -import { noteableDataMock } from '../../notes/mock_data'; +import { noteableDataMock } from 'jest/notes/mock_data'; import diffFileMockData from '../mock_data/diff_file'; import { diffMetadata } from '../mock_data/diff_metadata'; diff --git a/spec/frontend/editor/components/helpers.js b/spec/frontend/editor/components/helpers.js new file mode 100644 index 00000000000..3e6cd2a236d --- /dev/null +++ b/spec/frontend/editor/components/helpers.js @@ -0,0 +1,12 @@ +import { EDITOR_TOOLBAR_RIGHT_GROUP } from '~/editor/constants'; + +export const buildButton = (id = 'foo-bar-btn', options = {}) => { + return { + __typename: 'Item', + id, + label: options.label || 'Foo Bar Button', + icon: options.icon || 'foo-bar', + selected: options.selected || false, + group: options.group || EDITOR_TOOLBAR_RIGHT_GROUP, + }; +}; diff --git a/spec/frontend/editor/components/source_editor_toolbar_button_spec.js b/spec/frontend/editor/components/source_editor_toolbar_button_spec.js new file mode 100644 index 00000000000..5135091af4a --- /dev/null +++ b/spec/frontend/editor/components/source_editor_toolbar_button_spec.js @@ -0,0 +1,146 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import SourceEditorToolbarButton from '~/editor/components/source_editor_toolbar_button.vue'; +import getToolbarItemQuery from '~/editor/graphql/get_item.query.graphql'; +import updateToolbarItemMutation from '~/editor/graphql/update_item.mutation.graphql'; +import { buildButton } from './helpers'; + +Vue.use(VueApollo); + +describe('Source Editor Toolbar button', () => { + let wrapper; + let mockApollo; + const defaultBtn = buildButton(); + + const findButton = () => wrapper.findComponent(GlButton); + + const createComponentWithApollo = ({ propsData } = {}) => { + mockApollo = createMockApollo(); + mockApollo.clients.defaultClient.cache.writeQuery({ + query: getToolbarItemQuery, + variables: { id: defaultBtn.id }, + data: { + item: { + ...defaultBtn, + }, + }, + }); + + wrapper = shallowMount(SourceEditorToolbarButton, { + propsData, + apolloProvider: mockApollo, + }); + }; + + afterEach(() => { + wrapper.destroy(); + mockApollo = null; + }); + + describe('default', () => { + const defaultProps = { + category: 'primary', + variant: 'default', + }; + const customProps = { + category: 'secondary', + variant: 'info', + }; + it('renders a default button without props', async () => { + createComponentWithApollo(); + const btn = findButton(); + expect(btn.exists()).toBe(true); + expect(btn.props()).toMatchObject(defaultProps); + }); + + it('renders a button based on the props passed', async () => { + createComponentWithApollo({ + propsData: { + button: customProps, + }, + }); + const btn = findButton(); + expect(btn.props()).toMatchObject(customProps); + }); + }); + + describe('button updates', () => { + it('it properly updates button on Apollo cache update', async () => { + const { id } = defaultBtn; + + createComponentWithApollo({ + propsData: { + button: { + id, + }, + }, + }); + + expect(findButton().props('selected')).toBe(false); + + mockApollo.clients.defaultClient.cache.writeQuery({ + query: getToolbarItemQuery, + variables: { id }, + data: { + item: { + ...defaultBtn, + selected: true, + }, + }, + }); + + jest.runOnlyPendingTimers(); + await nextTick(); + + expect(findButton().props('selected')).toBe(true); + }); + }); + + describe('click handler', () => { + it('fires the click handler on the button when available', () => { + const spy = jest.fn(); + createComponentWithApollo({ + propsData: { + button: { + onClick: spy, + }, + }, + }); + expect(spy).not.toHaveBeenCalled(); + findButton().vm.$emit('click'); + expect(spy).toHaveBeenCalled(); + }); + it('emits the "click" event', () => { + createComponentWithApollo(); + jest.spyOn(wrapper.vm, '$emit'); + expect(wrapper.vm.$emit).not.toHaveBeenCalled(); + findButton().vm.$emit('click'); + expect(wrapper.vm.$emit).toHaveBeenCalledWith('click'); + }); + it('triggers the mutation exposing the changed "selected" prop', () => { + const { id } = defaultBtn; + createComponentWithApollo({ + propsData: { + button: { + id, + }, + }, + }); + jest.spyOn(wrapper.vm.$apollo, 'mutate'); + expect(wrapper.vm.$apollo.mutate).not.toHaveBeenCalled(); + findButton().vm.$emit('click'); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: updateToolbarItemMutation, + variables: { + id, + propsToUpdate: { + selected: true, + }, + }, + }); + }); + }); +}); diff --git a/spec/frontend/editor/components/source_editor_toolbar_spec.js b/spec/frontend/editor/components/source_editor_toolbar_spec.js new file mode 100644 index 00000000000..6e99eadbd97 --- /dev/null +++ b/spec/frontend/editor/components/source_editor_toolbar_spec.js @@ -0,0 +1,116 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlButtonGroup } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import SourceEditorToolbar from '~/editor/components/source_editor_toolbar.vue'; +import SourceEditorToolbarButton from '~/editor/components/source_editor_toolbar_button.vue'; +import { EDITOR_TOOLBAR_LEFT_GROUP, EDITOR_TOOLBAR_RIGHT_GROUP } from '~/editor/constants'; +import getToolbarItemsQuery from '~/editor/graphql/get_items.query.graphql'; +import { buildButton } from './helpers'; + +Vue.use(VueApollo); + +describe('Source Editor Toolbar', () => { + let wrapper; + let mockApollo; + + const findButtons = () => wrapper.findAllComponents(SourceEditorToolbarButton); + + const createApolloMockWithCache = (items = []) => { + mockApollo = createMockApollo(); + mockApollo.clients.defaultClient.cache.writeQuery({ + query: getToolbarItemsQuery, + data: { + items: { + nodes: items, + }, + }, + }); + }; + + const createComponentWithApollo = (items = []) => { + createApolloMockWithCache(items); + wrapper = shallowMount(SourceEditorToolbar, { + apolloProvider: mockApollo, + stubs: { + GlButtonGroup, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + mockApollo = null; + }); + + describe('groups', () => { + it.each` + group | expectedGroup + ${EDITOR_TOOLBAR_LEFT_GROUP} | ${EDITOR_TOOLBAR_LEFT_GROUP} + ${EDITOR_TOOLBAR_RIGHT_GROUP} | ${EDITOR_TOOLBAR_RIGHT_GROUP} + ${undefined} | ${EDITOR_TOOLBAR_RIGHT_GROUP} + ${'non-existing'} | ${EDITOR_TOOLBAR_RIGHT_GROUP} + `('puts item with group="$group" into $expectedGroup group', ({ group, expectedGroup }) => { + const item = buildButton('first', { + group, + }); + createComponentWithApollo([item]); + expect(findButtons()).toHaveLength(1); + [EDITOR_TOOLBAR_RIGHT_GROUP, EDITOR_TOOLBAR_LEFT_GROUP].forEach((g) => { + if (g === expectedGroup) { + expect(wrapper.vm.getGroupItems(g)).toEqual([expect.objectContaining({ id: 'first' })]); + } else { + expect(wrapper.vm.getGroupItems(g)).toHaveLength(0); + } + }); + }); + }); + + describe('buttons update', () => { + it('it properly updates buttons on Apollo cache update', async () => { + const item = buildButton('first', { + group: EDITOR_TOOLBAR_RIGHT_GROUP, + }); + createComponentWithApollo(); + + expect(findButtons()).toHaveLength(0); + + mockApollo.clients.defaultClient.cache.writeQuery({ + query: getToolbarItemsQuery, + data: { + items: { + nodes: [item], + }, + }, + }); + + jest.runOnlyPendingTimers(); + await nextTick(); + + expect(findButtons()).toHaveLength(1); + }); + }); + + describe('click handler', () => { + it('emits the "click" event when a button is clicked', () => { + const item1 = buildButton('first', { + group: EDITOR_TOOLBAR_LEFT_GROUP, + }); + const item2 = buildButton('second', { + group: EDITOR_TOOLBAR_RIGHT_GROUP, + }); + createComponentWithApollo([item1, item2]); + jest.spyOn(wrapper.vm, '$emit'); + expect(wrapper.vm.$emit).not.toHaveBeenCalled(); + + findButtons().at(0).vm.$emit('click'); + expect(wrapper.vm.$emit).toHaveBeenCalledWith('click', item1); + + findButtons().at(1).vm.$emit('click'); + expect(wrapper.vm.$emit).toHaveBeenCalledWith('click', item2); + + expect(wrapper.vm.$emit.mock.calls).toHaveLength(2); + }); + }); +}); diff --git a/spec/frontend/editor/schema/ci/ci_schema_spec.js b/spec/frontend/editor/schema/ci/ci_schema_spec.js new file mode 100644 index 00000000000..628c34a27c1 --- /dev/null +++ b/spec/frontend/editor/schema/ci/ci_schema_spec.js @@ -0,0 +1,90 @@ +import Ajv from 'ajv'; +import AjvFormats from 'ajv-formats'; +import CiSchema from '~/editor/schema/ci.json'; + +// JSON POSITIVE TESTS +import AllowFailureJson from './json_tests/positive_tests/allow_failure.json'; +import EnvironmentJson from './json_tests/positive_tests/environment.json'; +import GitlabCiDependenciesJson from './json_tests/positive_tests/gitlab-ci-dependencies.json'; +import GitlabCiJson from './json_tests/positive_tests/gitlab-ci.json'; +import InheritJson from './json_tests/positive_tests/inherit.json'; +import MultipleCachesJson from './json_tests/positive_tests/multiple-caches.json'; +import RetryJson from './json_tests/positive_tests/retry.json'; +import TerraformReportJson from './json_tests/positive_tests/terraform_report.json'; +import VariablesMixStringAndUserInputJson from './json_tests/positive_tests/variables_mix_string_and_user_input.json'; +import VariablesJson from './json_tests/positive_tests/variables.json'; + +// JSON NEGATIVE TESTS +import DefaultNoAdditionalPropertiesJson from './json_tests/negative_tests/default_no_additional_properties.json'; +import InheritDefaultNoAdditionalPropertiesJson from './json_tests/negative_tests/inherit_default_no_additional_properties.json'; +import JobVariablesMustNotContainObjectsJson from './json_tests/negative_tests/job_variables_must_not_contain_objects.json'; +import ReleaseAssetsLinksEmptyJson from './json_tests/negative_tests/release_assets_links_empty.json'; +import ReleaseAssetsLinksInvalidLinkTypeJson from './json_tests/negative_tests/release_assets_links_invalid_link_type.json'; +import ReleaseAssetsLinksMissingJson from './json_tests/negative_tests/release_assets_links_missing.json'; +import RetryUnknownWhenJson from './json_tests/negative_tests/retry_unknown_when.json'; + +// YAML POSITIVE TEST +import CacheYaml from './yaml_tests/positive_tests/cache.yml'; +import FilterYaml from './yaml_tests/positive_tests/filter.yml'; +import IncludeYaml from './yaml_tests/positive_tests/include.yml'; +import RulesYaml from './yaml_tests/positive_tests/rules.yml'; + +// YAML NEGATIVE TEST +import CacheNegativeYaml from './yaml_tests/negative_tests/cache.yml'; +import IncludeNegativeYaml from './yaml_tests/negative_tests/include.yml'; + +const ajv = new Ajv({ + strictTypes: false, + strictTuples: false, + allowMatchingProperties: true, +}); + +AjvFormats(ajv); +const schema = ajv.compile(CiSchema); + +describe('positive tests', () => { + it.each( + Object.entries({ + // JSON + AllowFailureJson, + EnvironmentJson, + GitlabCiDependenciesJson, + GitlabCiJson, + InheritJson, + MultipleCachesJson, + RetryJson, + TerraformReportJson, + VariablesMixStringAndUserInputJson, + VariablesJson, + + // YAML + CacheYaml, + FilterYaml, + IncludeYaml, + RulesYaml, + }), + )('schema validates %s', (_, input) => { + expect(input).toValidateJsonSchema(schema); + }); +}); + +describe('negative tests', () => { + it.each( + Object.entries({ + // JSON + DefaultNoAdditionalPropertiesJson, + JobVariablesMustNotContainObjectsJson, + InheritDefaultNoAdditionalPropertiesJson, + ReleaseAssetsLinksEmptyJson, + ReleaseAssetsLinksInvalidLinkTypeJson, + ReleaseAssetsLinksMissingJson, + RetryUnknownWhenJson, + + // YAML + CacheNegativeYaml, + IncludeNegativeYaml, + }), + )('schema validates %s', (_, input) => { + expect(input).not.toValidateJsonSchema(schema); + }); +}); diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/default_no_additional_properties.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/default_no_additional_properties.json new file mode 100644 index 00000000000..955c19ef1ab --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/default_no_additional_properties.json @@ -0,0 +1,12 @@ +{ + "default": { + "secrets": { + "DATABASE_PASSWORD": { + "vault": "production/db/password" + } + }, + "environment": { + "name": "test" + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/inherit_default_no_additional_properties.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/inherit_default_no_additional_properties.json new file mode 100644 index 00000000000..7411e4c2434 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/inherit_default_no_additional_properties.json @@ -0,0 +1,8 @@ +{ + "karma": { + "inherit": { + "default": ["secrets"] + }, + "script": "karma" + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/job_variables_must_not_contain_objects.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/job_variables_must_not_contain_objects.json new file mode 100644 index 00000000000..bfdbf26ee70 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/job_variables_must_not_contain_objects.json @@ -0,0 +1,12 @@ +{ + "gitlab-ci-variables-object": { + "stage": "test", + "script": ["true"], + "variables": { + "DEPLOY_ENVIRONMENT": { + "value": "staging", + "description": "The deployment target. Change this variable to 'canary' or 'production' if needed." + } + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_empty.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_empty.json new file mode 100644 index 00000000000..84a1aa14698 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_empty.json @@ -0,0 +1,13 @@ +{ + "gitlab-ci-release-assets-links-empty": { + "script": "dostuff", + "stage": "deploy", + "release": { + "description": "Created using the release-cli $EXTRA_DESCRIPTION", + "tag_name": "$CI_COMMIT_TAG", + "assets": { + "links": [] + } + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_invalid_link_type.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_invalid_link_type.json new file mode 100644 index 00000000000..048911aefa3 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_invalid_link_type.json @@ -0,0 +1,24 @@ +{ + "gitlab-ci-release-assets-links-invalid-link-type": { + "script": "dostuff", + "stage": "deploy", + "release": { + "description": "Created using the release-cli $EXTRA_DESCRIPTION", + "tag_name": "$CI_COMMIT_TAG", + "assets": { + "links": [ + { + "name": "asset1", + "url": "https://example.com/assets/1" + }, + { + "name": "asset2", + "url": "https://example.com/assets/2", + "filepath": "/pretty/url/1", + "link_type": "invalid" + } + ] + } + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_missing.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_missing.json new file mode 100644 index 00000000000..6f0b5a3bff8 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_missing.json @@ -0,0 +1,11 @@ +{ + "gitlab-ci-release-assets-links-missing": { + "script": "dostuff", + "stage": "deploy", + "release": { + "description": "Created using the release-cli $EXTRA_DESCRIPTION", + "tag_name": "$CI_COMMIT_TAG", + "assets": {} + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/retry_unknown_when.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/retry_unknown_when.json new file mode 100644 index 00000000000..433504f52c6 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/retry_unknown_when.json @@ -0,0 +1,9 @@ +{ + "gitlab-ci-retry-object-unknown-when": { + "stage": "test", + "script": "rspec", + "retry": { + "when": "gitlab-ci-retry-object-unknown-when" + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/allow_failure.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/allow_failure.json new file mode 100644 index 00000000000..44d42116c1a --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/allow_failure.json @@ -0,0 +1,19 @@ +{ + "job1": { + "stage": "test", + "script": ["execute_script_that_will_fail"], + "allow_failure": true + }, + "job2": { + "script": ["exit 1"], + "allow_failure": { + "exit_codes": 137 + } + }, + "job3": { + "script": ["exit 137"], + "allow_failure": { + "exit_codes": [137, 255] + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/environment.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/environment.json new file mode 100644 index 00000000000..0c6f7935063 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/environment.json @@ -0,0 +1,75 @@ +{ + "deploy to production 1": { + "stage": "deploy", + "script": "git push production HEAD: master", + "environment": "production" + }, + "deploy to production 2": { + "stage": "deploy", + "script": "git push production HEAD:master", + "environment": { + "name": "production" + } + }, + "deploy to production 3": { + "stage": "deploy", + "script": "git push production HEAD:master", + "environment": { + "name": "production", + "url": "https://prod.example.com" + } + }, + "review_app 1": { + "stage": "deploy", + "script": "make deploy-app", + "environment": { + "name": "review/$CI_COMMIT_REF_NAME", + "url": "https://$CI_ENVIRONMENT_SLUG.example.com", + "on_stop": "stop_review_app" + } + }, + "stop_review_app": { + "stage": "deploy", + "variables": { + "GIT_STRATEGY": "none" + }, + "script": "make delete-app", + "when": "manual", + "environment": { + "name": "review/$CI_COMMIT_REF_NAME", + "action": "stop" + } + }, + "review_app 2": { + "script": "deploy-review-app", + "environment": { + "name": "review/$CI_COMMIT_REF_NAME", + "auto_stop_in": "1 day" + } + }, + "deploy 1": { + "stage": "deploy", + "script": "make deploy-app", + "environment": { + "name": "production", + "kubernetes": { + "namespace": "production" + } + } + }, + "deploy 2": { + "script": "echo", + "environment": { + "name": "customer-portal", + "deployment_tier": "production" + } + }, + "deploy as review app": { + "stage": "deploy", + "script": "make deploy", + "environment": { + "name": "review/$CI_COMMIT_REF_NAME", + "url": "https://$CI_ENVIRONMENT_SLUG.example.com/" + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci-dependencies.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci-dependencies.json new file mode 100644 index 00000000000..5ffa7fa799e --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci-dependencies.json @@ -0,0 +1,68 @@ +{ + ".build_config": { + "before_script": ["echo test"] + }, + ".build_script": "echo build script", + "default": { + "image": "ruby:2.5", + "services": ["docker:dind"], + "cache": { + "paths": ["vendor/"] + }, + "before_script": ["bundle install --path vendor/"], + "after_script": ["rm -rf tmp/"] + }, + "stages": ["install", "build", "test", "deploy"], + "image": "foo:latest", + "install task1": { + "image": "node:latest", + "stage": "install", + "script": "npm install", + "artifacts": { + "paths": ["node_modules/"] + } + }, + "build dev": { + "image": "node:latest", + "stage": "build", + "needs": [ + { + "job": "install task1" + } + ], + "script": "npm run build:dev" + }, + "build prod": { + "image": "node:latest", + "stage": "build", + "needs": ["install task1"], + "script": "npm run build:prod" + }, + "test": { + "image": "node:latest", + "stage": "build", + "needs": [ + "install task1", + { + "job": "build dev", + "artifacts": true + } + ], + "script": "npm run test" + }, + "deploy it": { + "image": "node:latest", + "stage": "deploy", + "needs": [ + { + "job": "build dev", + "artifacts": false + }, + { + "job": "build prod", + "artifacts": true + } + ], + "script": "npm run test" + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json new file mode 100644 index 00000000000..89420bbc35f --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json @@ -0,0 +1,350 @@ +{ + ".build_config": { + "before_script": ["echo test"] + }, + ".build_script": "echo build script", + ".example_variables": { + "foo": "hello", + "bar": 42 + }, + ".example_services": [ + "docker:dind", + { + "name": "sql:latest", + "command": ["/usr/bin/super-sql", "run"] + } + ], + "default": { + "image": "ruby:2.5", + "services": ["docker:dind"], + "cache": { + "paths": ["vendor/"] + }, + "before_script": ["bundle install --path vendor/"], + "after_script": ["rm -rf tmp/"], + "tags": ["ruby", "postgres"], + "artifacts": { + "name": "%CI_COMMIT_REF_NAME%", + "expose_as": "artifact 1", + "paths": ["path/to/file.txt", "target/*.war"], + "when": "on_failure" + }, + "retry": 2, + "timeout": "3 hours 30 minutes", + "interruptible": true + }, + "stages": ["build", "test", "deploy", "random"], + "image": "foo:latest", + "services": ["sql:latest"], + "before_script": ["echo test", "echo test2"], + "after_script": [], + "cache": { + "key": "asd", + "paths": ["dist/", ".foo"], + "untracked": false, + "policy": "pull" + }, + "variables": { + "STAGE": "yep", + "PROD": "nope" + }, + "include": [ + "https://gitlab.com/awesome-project/raw/master/.before-script-template.yml", + "/templates/.after-script-template.yml", + { "template": "Auto-DevOps.gitlab-ci.yml" }, + { + "project": "my-group/my-project", + "ref": "master", + "file": "/templates/.gitlab-ci-template.yml" + }, + { + "project": "my-group/my-project", + "ref": "master", + "file": ["/templates/.gitlab-ci-template.yml", "/templates/another-template-to-include.yml"] + } + ], + "build": { + "image": { + "name": "node:latest" + }, + "services": [], + "stage": "build", + "script": "npm run build", + "before_script": ["npm install"], + "rules": [ + { + "if": "moo", + "changes": ["Moofile"], + "exists": ["main.cow"], + "when": "delayed", + "start_in": "3 hours" + } + ], + "retry": { + "max": 1, + "when": "stuck_or_timeout_failure" + }, + "cache": { + "key": "$CI_COMMIT_REF_NAME", + "paths": ["node_modules/"], + "policy": "pull-push" + }, + "artifacts": { + "paths": ["dist/"], + "expose_as": "link_name_in_merge_request", + "name": "bundles", + "when": "on_success", + "expire_in": "1 week", + "reports": { + "junit": "result.xml", + "cobertura": "cobertura-coverage.xml", + "codequality": "codequality.json", + "sast": "sast.json", + "dependency_scanning": "scan.json", + "container_scanning": "scan2.json", + "dast": "dast.json", + "license_management": "license.json", + "performance": "performance.json", + "metrics": "metrics.txt" + } + }, + "variables": { + "FOO_BAR": "..." + }, + "only": { + "kubernetes": "active", + "variables": ["$FOO_BAR == '...'"], + "changes": ["/path/to/file", "/another/file"] + }, + "except": ["master", "tags"], + "tags": ["docker"], + "allow_failure": true, + "when": "manual" + }, + "error-report": { + "when": "on_failure", + "script": "report error", + "stage": "test" + }, + "test": { + "image": { + "name": "node:latest", + "entrypoint": [""] + }, + "stage": "test", + "script": "npm test", + "parallel": 5, + "retry": { + "max": 2, + "when": [ + "runner_system_failure", + "stuck_or_timeout_failure", + "script_failure", + "unknown_failure", + "always" + ] + }, + "artifacts": { + "reports": { + "junit": ["result.xml"], + "cobertura": ["cobertura-coverage.xml"], + "codequality": ["codequality.json"], + "sast": ["sast.json"], + "dependency_scanning": ["scan.json"], + "container_scanning": ["scan2.json"], + "dast": ["dast.json"], + "license_management": ["license.json"], + "performance": ["performance.json"], + "metrics": ["metrics.txt"] + } + }, + "coverage": "/Cycles: \\d+\\.\\d+$/", + "dependencies": [] + }, + "docker": { + "script": "docker build -t foo:latest", + "when": "delayed", + "start_in": "10 min", + "timeout": "1h", + "retry": 1, + "only": { + "changes": ["Dockerfile", "docker/scripts/*"] + } + }, + "deploy": { + "services": [ + { + "name": "sql:latest", + "entrypoint": [""], + "command": ["/usr/bin/super-sql", "run"], + "alias": "super-sql" + }, + "sql:latest", + { + "name": "sql:latest", + "alias": "default-sql" + } + ], + "script": "dostuff", + "stage": "deploy", + "environment": { + "name": "prod", + "url": "http://example.com", + "on_stop": "stop-deploy" + }, + "only": ["master"], + "release": { + "name": "Release $CI_COMMIT_TAG", + "description": "Created using the release-cli $EXTRA_DESCRIPTION", + "tag_name": "$CI_COMMIT_TAG", + "ref": "$CI_COMMIT_TAG", + "milestones": ["m1", "m2", "m3"], + "released_at": "2020-07-15T08:00:00Z", + "assets": { + "links": [ + { + "name": "asset1", + "url": "https://example.com/assets/1" + }, + { + "name": "asset2", + "url": "https://example.com/assets/2", + "filepath": "/pretty/url/1", + "link_type": "other" + }, + { + "name": "asset3", + "url": "https://example.com/assets/3", + "link_type": "runbook" + }, + { + "name": "asset4", + "url": "https://example.com/assets/4", + "link_type": "package" + }, + { + "name": "asset5", + "url": "https://example.com/assets/5", + "link_type": "image" + } + ] + } + } + }, + ".performance-tmpl": { + "after_script": ["echo after"], + "before_script": ["echo before"], + "variables": { + "SCRIPT_NOT_REQUIRED": "true" + } + }, + "performance-a": { + "extends": ".performance-tmpl", + "script": "echo test" + }, + "performance-b": { + "extends": ".performance-tmpl" + }, + "workflow": { + "rules": [ + { + "if": "$CI_COMMIT_REF_NAME =~ /-wip$/", + "when": "never" + }, + { + "if": "$CI_COMMIT_TAG", + "when": "never" + }, + { + "when": "always" + } + ] + }, + "job": { + "script": "echo Hello, Rules!", + "rules": [ + { + "if": "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == \"master\"", + "when": "manual", + "allow_failure": true + } + ] + }, + "microservice_a": { + "trigger": { + "include": "path/to/microservice_a.yml" + } + }, + "microservice_b": { + "trigger": { + "include": [{ "local": "path/to/microservice_b.yml" }, { "template": "SAST.gitlab-cy.yml" }], + "strategy": "depend" + } + }, + "child-pipeline": { + "stage": "test", + "trigger": { + "include": [ + { + "artifact": "generated-config.yml", + "job": "generate-config" + } + ] + } + }, + "child-pipeline-simple": { + "stage": "test", + "trigger": { + "include": "other/file.yml" + } + }, + "complex": { + "stage": "deploy", + "trigger": { + "project": "my/deployment", + "branch": "stable" + } + }, + "parallel-integer": { + "stage": "test", + "script": ["echo ${CI_NODE_INDEX} ${CI_NODE_TOTAL}"], + "parallel": 5 + }, + "parallel-matrix-simple": { + "stage": "test", + "script": ["echo ${MY_VARIABLE}"], + "parallel": { + "matrix": [ + { + "MY_VARIABLE": 0 + }, + { + "MY_VARIABLE": "sample" + }, + { + "MY_VARIABLE": ["element0", 1, "element2"] + } + ] + } + }, + "parallel-matrix-gitlab-docs": { + "stage": "deploy", + "script": ["bin/deploy"], + "parallel": { + "matrix": [ + { + "PROVIDER": "aws", + "STACK": ["app1", "app2"] + }, + { + "PROVIDER": "ovh", + "STACK": ["monitoring", "backup", "app"] + }, + { + "PROVIDER": ["gcp", "vultr"], + "STACK": ["data", "processing"] + } + ] + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/inherit.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/inherit.json new file mode 100644 index 00000000000..3f72afa6ceb --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/inherit.json @@ -0,0 +1,54 @@ +{ + "default": { + "image": "ruby:2.4", + "before_script": ["echo Hello World"] + }, + "variables": { + "DOMAIN": "example.com", + "WEBHOOK_URL": "https://my-webhook.example.com" + }, + "rubocop": { + "inherit": { + "default": false, + "variables": false + }, + "script": "bundle exec rubocop" + }, + "rspec": { + "inherit": { + "default": ["image"], + "variables": ["WEBHOOK_URL"] + }, + "script": "bundle exec rspec" + }, + "capybara": { + "inherit": { + "variables": false + }, + "script": "bundle exec capybara" + }, + "karma": { + "inherit": { + "default": true, + "variables": ["DOMAIN"] + }, + "script": "karma" + }, + "inherit literally all": { + "inherit": { + "default": [ + "after_script", + "artifacts", + "before_script", + "cache", + "image", + "interruptible", + "retry", + "services", + "tags", + "timeout" + ] + }, + "script": "true" + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/multiple-caches.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/multiple-caches.json new file mode 100644 index 00000000000..360938e5ce7 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/multiple-caches.json @@ -0,0 +1,24 @@ +{ + "test-job": { + "stage": "build", + "cache": [ + { + "key": { + "files": ["Gemfile.lock"] + }, + "paths": ["vendor/ruby"] + }, + { + "key": { + "files": ["yarn.lock"] + }, + "paths": [".yarn-cache/"] + } + ], + "script": [ + "bundle install --path=vendor", + "yarn install --cache-folder .yarn-cache", + "echo Run tests..." + ] + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/retry.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/retry.json new file mode 100644 index 00000000000..1337e5e7bc8 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/retry.json @@ -0,0 +1,60 @@ +{ + "gitlab-ci-retry-int": { + "stage": "test", + "script": "rspec", + "retry": 2 + }, + "gitlab-ci-retry-object-no-max": { + "stage": "test", + "script": "rspec", + "retry": { + "when": "runner_system_failure" + } + }, + "gitlab-ci-retry-object-single-when": { + "stage": "test", + "script": "rspec", + "retry": { + "max": 2, + "when": "runner_system_failure" + } + }, + "gitlab-ci-retry-object-multiple-when": { + "stage": "test", + "script": "rspec", + "retry": { + "max": 2, + "when": ["runner_system_failure", "stuck_or_timeout_failure"] + } + }, + "gitlab-ci-retry-object-multiple-when-dupes": { + "stage": "test", + "script": "rspec", + "retry": { + "max": 2, + "when": ["runner_system_failure", "runner_system_failure"] + } + }, + "gitlab-ci-retry-object-all-when": { + "stage": "test", + "script": "rspec", + "retry": { + "max": 2, + "when": [ + "always", + "unknown_failure", + "script_failure", + "api_failure", + "stuck_or_timeout_failure", + "runner_system_failure", + "runner_unsupported", + "stale_schedule", + "job_execution_timeout", + "archived_failure", + "unmet_prerequisites", + "scheduler_failure", + "data_integrity_failure" + ] + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/terraform_report.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/terraform_report.json new file mode 100644 index 00000000000..0e444a4ba62 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/terraform_report.json @@ -0,0 +1,50 @@ +{ + "image": { + "name": "registry.gitlab.com/gitlab-org/gitlab-build-images:terraform", + "entrypoint": [ + "/usr/bin/env", + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + ] + }, + "variables": { + "PLAN": "plan.tfplan", + "JSON_PLAN_FILE": "tfplan.json" + }, + "cache": { + "paths": [".terraform"] + }, + "before_script": [ + "alias convert_report=\"jq -r '([.resource_changes[]?.change.actions?]|flatten)|{\"create\":(map(select(.==\"create\"))|length),\"update\":(map(select(.==\"update\"))|length),\"delete\":(map(select(.==\"delete\"))|length)}'\"", + "terraform --version", + "terraform init" + ], + "stages": ["validate", "build", "test", "deploy"], + "validate": { + "stage": "validate", + "script": ["terraform validate"] + }, + "plan": { + "stage": "build", + "script": [ + "terraform plan -out=$PLAN", + "terraform show --json $PLAN | convert_report > $JSON_PLAN_FILE" + ], + "artifacts": { + "name": "plan", + "paths": ["$PLAN"], + "reports": { + "terraform": "$JSON_PLAN_FILE" + } + } + }, + "apply": { + "stage": "deploy", + "environment": { + "name": "production" + }, + "script": ["terraform apply -input=false $PLAN"], + "dependencies": ["plan"], + "when": "manual", + "only": ["master"] + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables.json new file mode 100644 index 00000000000..ce59b3fbbec --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables.json @@ -0,0 +1,22 @@ +{ + "variables": { + "DEPLOY_ENVIRONMENT": { + "value": "staging", + "description": "The deployment target. Change this variable to 'canary' or 'production' if needed." + } + }, + "gitlab-ci-variables-string": { + "stage": "test", + "script": ["true"], + "variables": { + "TEST_VAR": "String variable" + } + }, + "gitlab-ci-variables-integer": { + "stage": "test", + "script": ["true"], + "variables": { + "canonical": 685230 + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables_mix_string_and_user_input.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables_mix_string_and_user_input.json new file mode 100644 index 00000000000..87a9ec05b57 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables_mix_string_and_user_input.json @@ -0,0 +1,10 @@ +{ + "variables": { + "SOME_STR": "--batch-mode --errors --fail-at-end --show-version", + "SOME_INT": 10, + "SOME_USER_INPUT_FLAG": { + "value": "flag value", + "description": "Some Flag!" + } + } +} diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/cache.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/cache.yml new file mode 100644 index 00000000000..ee533f54d3b --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/cache.yml @@ -0,0 +1,15 @@ +# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70779 +stages: + - prepare + +# invalid cache:when value +job1: + stage: prepare + cache: + when: 0 + +# invalid cache:when value +job2: + stage: prepare + cache: + when: 'never' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml new file mode 100644 index 00000000000..287150a765f --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml @@ -0,0 +1,17 @@ +# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70779 +stages: + - prepare + +# missing file property +childPipeline: + stage: prepare + trigger: + include: + - project: 'my-group/my-pipeline-library' + +# missing project property +childPipeline2: + stage: prepare + trigger: + include: + - file: '.gitlab-ci.yml' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/cache.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/cache.yml new file mode 100644 index 00000000000..436c7d72699 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/cache.yml @@ -0,0 +1,25 @@ +# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70779 +stages: + - prepare + +# test for cache:when values +job1: + stage: prepare + script: + - echo 'running job' + cache: + when: 'on_success' + +job2: + stage: prepare + script: + - echo 'running job' + cache: + when: 'on_failure' + +job3: + stage: prepare + script: + - echo 'running job' + cache: + when: 'always' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/filter.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/filter.yml new file mode 100644 index 00000000000..2b29c24fa3c --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/filter.yml @@ -0,0 +1,18 @@ +# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79335 +deploy-template: + script: + - echo "hello world" + only: + - foo + except: + - bar + +# null value allowed +deploy-without-only: + extends: deploy-template + only: + +# null value allowed +deploy-without-except: + extends: deploy-template + except: diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml new file mode 100644 index 00000000000..3497be28058 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml @@ -0,0 +1,32 @@ +# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70779 + +# test for include:rules +include: + - local: builds.yml + rules: + - if: '$INCLUDE_BUILDS == "true"' + when: always + +stages: + - prepare + +# test for trigger:include +childPipeline: + stage: prepare + script: + - echo 'creating pipeline...' + trigger: + include: + - project: 'my-group/my-pipeline-library' + file: '.gitlab-ci.yml' + +# accepts optional ref property +childPipeline2: + stage: prepare + script: + - echo 'creating pipeline...' + trigger: + include: + - project: 'my-group/my-pipeline-library' + file: '.gitlab-ci.yml' + ref: 'main' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml new file mode 100644 index 00000000000..27a199cff13 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml @@ -0,0 +1,13 @@ +# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74164 + +# test for workflow:rules:changes and workflow:rules:exists +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "schedule"' + exists: + - Dockerfile + changes: + - Dockerfile + variables: + IS_A_FEATURE: 'true' + when: always diff --git a/spec/frontend/environments/deploy_board_component_spec.js b/spec/frontend/environments/deploy_board_component_spec.js index f0fb4d1027c..6bf87f7b07f 100644 --- a/spec/frontend/environments/deploy_board_component_spec.js +++ b/spec/frontend/environments/deploy_board_component_spec.js @@ -23,9 +23,9 @@ describe('Deploy Board', () => { }); describe('with valid data', () => { - beforeEach((done) => { + beforeEach(() => { wrapper = createComponent(); - nextTick(done); + return nextTick(); }); it('should render percentage with completion value provided', () => { @@ -127,14 +127,14 @@ describe('Deploy Board', () => { }); describe('with empty state', () => { - beforeEach((done) => { + beforeEach(() => { wrapper = createComponent({ deployBoardData: {}, isLoading: false, isEmpty: true, logsPath, }); - nextTick(done); + return nextTick(); }); it('should render the empty state', () => { @@ -146,14 +146,14 @@ describe('Deploy Board', () => { }); describe('with loading state', () => { - beforeEach((done) => { + beforeEach(() => { wrapper = createComponent({ deployBoardData: {}, isLoading: true, isEmpty: false, logsPath, }); - nextTick(done); + return nextTick(); }); it('should render loading spinner', () => { @@ -163,7 +163,7 @@ describe('Deploy Board', () => { describe('has legend component', () => { let statuses = []; - beforeEach((done) => { + beforeEach(() => { wrapper = createComponent({ isLoading: false, isEmpty: false, @@ -171,7 +171,7 @@ describe('Deploy Board', () => { deployBoardData: deployBoardMockData, }); ({ statuses } = wrapper.vm); - nextTick(done); + return nextTick(); }); it('with all the possible statuses', () => { diff --git a/spec/frontend/environments/empty_state_spec.js b/spec/frontend/environments/empty_state_spec.js new file mode 100644 index 00000000000..974afc6d032 --- /dev/null +++ b/spec/frontend/environments/empty_state_spec.js @@ -0,0 +1,53 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { s__ } from '~/locale'; +import EmptyState from '~/environments/components/empty_state.vue'; +import { ENVIRONMENTS_SCOPE } from '~/environments/constants'; + +const HELP_PATH = '/help'; + +describe('~/environments/components/empty_state.vue', () => { + let wrapper; + + const createWrapper = ({ propsData = {} } = {}) => + mountExtended(EmptyState, { + propsData: { + scope: ENVIRONMENTS_SCOPE.AVAILABLE, + helpPath: HELP_PATH, + ...propsData, + }, + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('shows an empty state for available environments', () => { + wrapper = createWrapper(); + + const title = wrapper.findByRole('heading', { + name: s__("Environments|You don't have any environments."), + }); + + expect(title.exists()).toBe(true); + }); + + it('shows an empty state for stopped environments', () => { + wrapper = createWrapper({ propsData: { scope: ENVIRONMENTS_SCOPE.STOPPED } }); + + const title = wrapper.findByRole('heading', { + name: s__("Environments|You don't have any stopped environments."), + }); + + expect(title.exists()).toBe(true); + }); + + it('shows a link to the the help path', () => { + wrapper = createWrapper(); + + const link = wrapper.findByRole('link', { + name: s__('Environments|How do I create an environment?'), + }); + + expect(link.attributes('href')).toBe(HELP_PATH); + }); +}); diff --git a/spec/frontend/environments/emtpy_state_spec.js b/spec/frontend/environments/emtpy_state_spec.js deleted file mode 100644 index 862d90e50dc..00000000000 --- a/spec/frontend/environments/emtpy_state_spec.js +++ /dev/null @@ -1,24 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import EmptyState from '~/environments/components/empty_state.vue'; - -describe('environments empty state', () => { - let vm; - - beforeEach(() => { - vm = shallowMount(EmptyState, { - propsData: { - helpPath: 'bar', - }, - }); - }); - - afterEach(() => { - vm.destroy(); - }); - - it('renders the empty state', () => { - expect(vm.find('.js-blank-state-title').text()).toEqual( - "You don't have any environments right now", - ); - }); -}); diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js index 0b36d2a940d..0761d04229c 100644 --- a/spec/frontend/environments/environment_item_spec.js +++ b/spec/frontend/environments/environment_item_spec.js @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils'; +import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; import { cloneDeep } from 'lodash'; import { format } from 'timeago.js'; import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper'; @@ -44,10 +45,16 @@ describe('Environment item', () => { const findAutoStop = () => wrapper.find('.js-auto-stop'); const findUpcomingDeployment = () => wrapper.find('[data-testid="upcoming-deployment"]'); + const findLastDeployment = () => wrapper.find('[data-testid="environment-deployment-id-cell"]'); const findUpcomingDeploymentContent = () => wrapper.find('[data-testid="upcoming-deployment-content"]'); const findUpcomingDeploymentStatusLink = () => wrapper.find('[data-testid="upcoming-deployment-status-link"]'); + const findLastDeploymentAvatarLink = () => findLastDeployment().findComponent(GlAvatarLink); + const findLastDeploymentAvatar = () => findLastDeployment().findComponent(GlAvatar); + const findUpcomingDeploymentAvatarLink = () => + findUpcomingDeployment().findComponent(GlAvatarLink); + const findUpcomingDeploymentAvatar = () => findUpcomingDeployment().findComponent(GlAvatar); afterEach(() => { wrapper.destroy(); @@ -79,9 +86,19 @@ describe('Environment item', () => { describe('With user information', () => { it('should render user avatar with link to profile', () => { - expect(wrapper.find('.js-deploy-user-container').props('linkHref')).toEqual( - environment.last_deployment.user.web_url, - ); + const avatarLink = findLastDeploymentAvatarLink(); + const avatar = findLastDeploymentAvatar(); + const { username, avatar_url, web_url } = environment.last_deployment.user; + + expect(avatarLink.attributes('href')).toBe(web_url); + expect(avatar.props()).toMatchObject({ + src: avatar_url, + entityName: username, + }); + expect(avatar.attributes()).toMatchObject({ + title: username, + alt: `${username}'s avatar`, + }); }); }); @@ -108,9 +125,16 @@ describe('Environment item', () => { describe('When the envionment has an upcoming deployment', () => { describe('When the upcoming deployment has a deployable', () => { it('should render the build ID and user', () => { - expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText( - '#27 by upcoming-username', - ); + const avatarLink = findUpcomingDeploymentAvatarLink(); + const avatar = findUpcomingDeploymentAvatar(); + const { username, avatar_url, web_url } = environment.upcoming_deployment.user; + + expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText('#27 by'); + expect(avatarLink.attributes('href')).toBe(web_url); + expect(avatar.props()).toMatchObject({ + src: avatar_url, + entityName: username, + }); }); it('should render a status icon with a link and tooltip', () => { @@ -139,10 +163,17 @@ describe('Environment item', () => { }); }); - it('should still renders the build ID and user', () => { - expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText( - '#27 by upcoming-username', - ); + it('should still render the build ID and user avatar', () => { + const avatarLink = findUpcomingDeploymentAvatarLink(); + const avatar = findUpcomingDeploymentAvatar(); + const { username, avatar_url, web_url } = environment.upcoming_deployment.user; + + expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText('#27 by'); + expect(avatarLink.attributes('href')).toBe(web_url); + expect(avatar.props()).toMatchObject({ + src: avatar_url, + entityName: username, + }); }); it('should not render the status icon', () => { @@ -383,7 +414,7 @@ describe('Environment item', () => { }); it('should hide non-folder properties', () => { - expect(wrapper.find('[data-testid="environment-deployment-id-cell"]').exists()).toBe(false); + expect(findLastDeployment().exists()).toBe(false); expect(wrapper.find('[data-testid="environment-build-cell"]').exists()).toBe(false); }); }); diff --git a/spec/frontend/environments/environment_table_spec.js b/spec/frontend/environments/environment_table_spec.js index c7582e4b06d..666e87c748e 100644 --- a/spec/frontend/environments/environment_table_spec.js +++ b/spec/frontend/environments/environment_table_spec.js @@ -122,7 +122,7 @@ describe('Environment table', () => { expect(wrapper.find('.deploy-board-icon').exists()).toBe(true); }); - it('should toggle deploy board visibility when arrow is clicked', (done) => { + it('should toggle deploy board visibility when arrow is clicked', async () => { const mockItem = { name: 'review', size: 1, @@ -142,7 +142,6 @@ describe('Environment table', () => { eventHub.$on('toggleDeployBoard', (env) => { expect(env.id).toEqual(mockItem.id); - done(); }); factory({ @@ -154,7 +153,7 @@ describe('Environment table', () => { }, }); - wrapper.find('.deploy-board-icon').trigger('click'); + await wrapper.find('.deploy-board-icon').trigger('click'); }); it('should set the environment to change and weight when a change canary weight event is recevied', async () => { diff --git a/spec/frontend/environments/graphql/mock_data.js b/spec/frontend/environments/graphql/mock_data.js index 1b7b35702de..7e436476a8f 100644 --- a/spec/frontend/environments/graphql/mock_data.js +++ b/spec/frontend/environments/graphql/mock_data.js @@ -543,6 +543,7 @@ export const resolvedEnvironment = { externalUrl: 'https://example.org', environmentType: 'review', nameWithoutType: 'hello', + tier: 'development', lastDeployment: { id: 78, iid: 24, @@ -551,6 +552,7 @@ export const resolvedEnvironment = { status: 'success', createdAt: '2022-01-07T15:47:27.415Z', deployedAt: '2022-01-07T15:47:32.450Z', + tierInYaml: 'staging', tag: false, isLast: true, user: { diff --git a/spec/frontend/environments/new_environment_item_spec.js b/spec/frontend/environments/new_environment_item_spec.js index 1d7a33fb95b..cf0c8a7e7ca 100644 --- a/spec/frontend/environments/new_environment_item_spec.js +++ b/spec/frontend/environments/new_environment_item_spec.js @@ -73,6 +73,34 @@ describe('~/environments/components/new_environment_item.vue', () => { expect(name.text()).toHaveLength(80); }); + describe('tier', () => { + it('displays the tier of the environment when defined in yaml', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const tier = wrapper.findByTitle(s__('Environment|Deployment tier')); + + expect(tier.text()).toBe(resolvedEnvironment.lastDeployment.tierInYaml); + }); + + it('does not display the tier if not defined in yaml', () => { + const environment = { + ...resolvedEnvironment, + lastDeployment: { + ...resolvedEnvironment.lastDeployment, + tierInYaml: null, + }, + }; + wrapper = createWrapper({ + propsData: { environment }, + apolloProvider: createApolloProvider(), + }); + + const tier = wrapper.findByTitle(s__('Environment|Deployment tier')); + + expect(tier.exists()).toBe(false); + }); + }); + describe('url', () => { it('shows a link for the url if one is present', () => { wrapper = createWrapper({ apolloProvider: createApolloProvider() }); diff --git a/spec/frontend/error_tracking/store/actions_spec.js b/spec/frontend/error_tracking/store/actions_spec.js index aaaa1194a29..6bac21341a7 100644 --- a/spec/frontend/error_tracking/store/actions_spec.js +++ b/spec/frontend/error_tracking/store/actions_spec.js @@ -28,9 +28,9 @@ describe('Sentry common store actions', () => { const params = { endpoint, redirectUrl, status }; describe('updateStatus', () => { - it('should handle successful status update', (done) => { + it('should handle successful status update', async () => { mock.onPut().reply(200, {}); - testAction( + await testAction( actions.updateStatus, params, {}, @@ -41,20 +41,15 @@ describe('Sentry common store actions', () => { }, ], [], - () => { - done(); - expect(visitUrl).toHaveBeenCalledWith(redirectUrl); - }, ); + expect(visitUrl).toHaveBeenCalledWith(redirectUrl); }); - it('should handle unsuccessful status update', (done) => { + it('should handle unsuccessful status update', async () => { mock.onPut().reply(400, {}); - testAction(actions.updateStatus, params, {}, [], [], () => { - expect(visitUrl).not.toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledTimes(1); - done(); - }); + await testAction(actions.updateStatus, params, {}, [], []); + expect(visitUrl).not.toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalledTimes(1); }); }); diff --git a/spec/frontend/error_tracking/store/details/actions_spec.js b/spec/frontend/error_tracking/store/details/actions_spec.js index 623cb82851d..a3a6f7cc309 100644 --- a/spec/frontend/error_tracking/store/details/actions_spec.js +++ b/spec/frontend/error_tracking/store/details/actions_spec.js @@ -28,10 +28,10 @@ describe('Sentry error details store actions', () => { describe('startPollingStacktrace', () => { const endpoint = '123/stacktrace'; - it('should commit SET_ERROR with received response', (done) => { + it('should commit SET_ERROR with received response', () => { const payload = { error: [1, 2, 3] }; mockedAdapter.onGet().reply(200, payload); - testAction( + return testAction( actions.startPollingStacktrace, { endpoint }, {}, @@ -40,37 +40,29 @@ describe('Sentry error details store actions', () => { { type: types.SET_LOADING_STACKTRACE, payload: false }, ], [], - () => { - done(); - }, ); }); - it('should show flash on API error', (done) => { + it('should show flash on API error', async () => { mockedAdapter.onGet().reply(400); - testAction( + await testAction( actions.startPollingStacktrace, { endpoint }, {}, [{ type: types.SET_LOADING_STACKTRACE, payload: false }], [], - () => { - expect(createFlash).toHaveBeenCalledTimes(1); - done(); - }, ); + expect(createFlash).toHaveBeenCalledTimes(1); }); - it('should not restart polling when receiving an empty 204 response', (done) => { + it('should not restart polling when receiving an empty 204 response', async () => { mockedRestart = jest.spyOn(Poll.prototype, 'restart'); mockedAdapter.onGet().reply(204); - testAction(actions.startPollingStacktrace, { endpoint }, {}, [], [], () => { - mockedRestart = jest.spyOn(Poll.prototype, 'restart'); - expect(mockedRestart).toHaveBeenCalledTimes(0); - done(); - }); + await testAction(actions.startPollingStacktrace, { endpoint }, {}, [], []); + mockedRestart = jest.spyOn(Poll.prototype, 'restart'); + expect(mockedRestart).toHaveBeenCalledTimes(0); }); }); }); diff --git a/spec/frontend/error_tracking/store/list/actions_spec.js b/spec/frontend/error_tracking/store/list/actions_spec.js index 5465bde397c..7173f68bb96 100644 --- a/spec/frontend/error_tracking/store/list/actions_spec.js +++ b/spec/frontend/error_tracking/store/list/actions_spec.js @@ -20,11 +20,11 @@ describe('error tracking actions', () => { }); describe('startPolling', () => { - it('should start polling for data', (done) => { + it('should start polling for data', () => { const payload = { errors: [{ id: 1 }, { id: 2 }] }; mock.onGet().reply(httpStatusCodes.OK, payload); - testAction( + return testAction( actions.startPolling, {}, {}, @@ -35,16 +35,13 @@ describe('error tracking actions', () => { { type: types.SET_LOADING, payload: false }, ], [{ type: 'stopPolling' }], - () => { - done(); - }, ); }); - it('should show flash on API error', (done) => { + it('should show flash on API error', async () => { mock.onGet().reply(httpStatusCodes.BAD_REQUEST); - testAction( + await testAction( actions.startPolling, {}, {}, @@ -53,11 +50,8 @@ describe('error tracking actions', () => { { type: types.SET_LOADING, payload: false }, ], [], - () => { - expect(createFlash).toHaveBeenCalledTimes(1); - done(); - }, ); + expect(createFlash).toHaveBeenCalledTimes(1); }); }); diff --git a/spec/frontend/error_tracking_settings/store/actions_spec.js b/spec/frontend/error_tracking_settings/store/actions_spec.js index 1b9be042dd4..bcd816c2ae0 100644 --- a/spec/frontend/error_tracking_settings/store/actions_spec.js +++ b/spec/frontend/error_tracking_settings/store/actions_spec.js @@ -27,9 +27,9 @@ describe('error tracking settings actions', () => { refreshCurrentPage.mockClear(); }); - it('should request and transform the project list', (done) => { + it('should request and transform the project list', async () => { mock.onGet(TEST_HOST).reply(() => [200, { projects: projectList }]); - testAction( + await testAction( actions.fetchProjects, null, state, @@ -41,16 +41,13 @@ describe('error tracking settings actions', () => { payload: projectList.map(convertObjectPropsToCamelCase), }, ], - () => { - expect(mock.history.get.length).toBe(1); - done(); - }, ); + expect(mock.history.get.length).toBe(1); }); - it('should handle a server error', (done) => { + it('should handle a server error', async () => { mock.onGet(`${TEST_HOST}.json`).reply(() => [400]); - testAction( + await testAction( actions.fetchProjects, null, state, @@ -61,27 +58,23 @@ describe('error tracking settings actions', () => { type: 'receiveProjectsError', }, ], - () => { - expect(mock.history.get.length).toBe(1); - done(); - }, ); + expect(mock.history.get.length).toBe(1); }); - it('should request projects correctly', (done) => { - testAction( + it('should request projects correctly', () => { + return testAction( actions.requestProjects, null, state, [{ type: types.SET_PROJECTS_LOADING, payload: true }, { type: types.RESET_CONNECT }], [], - done, ); }); - it('should receive projects correctly', (done) => { + it('should receive projects correctly', () => { const testPayload = []; - testAction( + return testAction( actions.receiveProjectsSuccess, testPayload, state, @@ -91,13 +84,12 @@ describe('error tracking settings actions', () => { { type: types.SET_PROJECTS_LOADING, payload: false }, ], [], - done, ); }); - it('should handle errors when receiving projects', (done) => { + it('should handle errors when receiving projects', () => { const testPayload = []; - testAction( + return testAction( actions.receiveProjectsError, testPayload, state, @@ -107,7 +99,6 @@ describe('error tracking settings actions', () => { { type: types.SET_PROJECTS_LOADING, payload: false }, ], [], - done, ); }); }); @@ -126,18 +117,16 @@ describe('error tracking settings actions', () => { mock.restore(); }); - it('should save the page', (done) => { + it('should save the page', async () => { mock.onPatch(TEST_HOST).reply(200); - testAction(actions.updateSettings, null, state, [], [{ type: 'requestSettings' }], () => { - expect(mock.history.patch.length).toBe(1); - expect(refreshCurrentPage).toHaveBeenCalled(); - done(); - }); + await testAction(actions.updateSettings, null, state, [], [{ type: 'requestSettings' }]); + expect(mock.history.patch.length).toBe(1); + expect(refreshCurrentPage).toHaveBeenCalled(); }); - it('should handle a server error', (done) => { + it('should handle a server error', async () => { mock.onPatch(TEST_HOST).reply(400); - testAction( + await testAction( actions.updateSettings, null, state, @@ -149,57 +138,50 @@ describe('error tracking settings actions', () => { payload: new Error('Request failed with status code 400'), }, ], - () => { - expect(mock.history.patch.length).toBe(1); - done(); - }, ); + expect(mock.history.patch.length).toBe(1); }); - it('should request to save the page', (done) => { - testAction( + it('should request to save the page', () => { + return testAction( actions.requestSettings, null, state, [{ type: types.UPDATE_SETTINGS_LOADING, payload: true }], [], - done, ); }); - it('should handle errors when requesting to save the page', (done) => { - testAction( + it('should handle errors when requesting to save the page', () => { + return testAction( actions.receiveSettingsError, {}, state, [{ type: types.UPDATE_SETTINGS_LOADING, payload: false }], [], - done, ); }); }); describe('generic actions to update the store', () => { const testData = 'test'; - it('should reset the `connect success` flag when updating the api host', (done) => { - testAction( + it('should reset the `connect success` flag when updating the api host', () => { + return testAction( actions.updateApiHost, testData, state, [{ type: types.UPDATE_API_HOST, payload: testData }, { type: types.RESET_CONNECT }], [], - done, ); }); - it('should reset the `connect success` flag when updating the token', (done) => { - testAction( + it('should reset the `connect success` flag when updating the token', () => { + return testAction( actions.updateToken, testData, state, [{ type: types.UPDATE_TOKEN, payload: testData }, { type: types.RESET_CONNECT }], [], - done, ); }); diff --git a/spec/frontend/feature_flags/store/edit/actions_spec.js b/spec/frontend/feature_flags/store/edit/actions_spec.js index 12fccd79170..b6114cb0c9f 100644 --- a/spec/frontend/feature_flags/store/edit/actions_spec.js +++ b/spec/frontend/feature_flags/store/edit/actions_spec.js @@ -40,7 +40,7 @@ describe('Feature flags Edit Module actions', () => { }); describe('success', () => { - it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagSuccess ', (done) => { + it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagSuccess ', () => { const featureFlag = { name: 'name', description: 'description', @@ -57,7 +57,7 @@ describe('Feature flags Edit Module actions', () => { }; mock.onPut(mockedState.endpoint, mapStrategiesToRails(featureFlag)).replyOnce(200); - testAction( + return testAction( updateFeatureFlag, featureFlag, mockedState, @@ -70,16 +70,15 @@ describe('Feature flags Edit Module actions', () => { type: 'receiveUpdateFeatureFlagSuccess', }, ], - done, ); }); }); describe('error', () => { - it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagError ', (done) => { + it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagError ', () => { mock.onPut(`${TEST_HOST}/endpoint.json`).replyOnce(500, { message: [] }); - testAction( + return testAction( updateFeatureFlag, { name: 'feature_flag', @@ -97,28 +96,26 @@ describe('Feature flags Edit Module actions', () => { payload: { message: [] }, }, ], - done, ); }); }); }); describe('requestUpdateFeatureFlag', () => { - it('should commit REQUEST_UPDATE_FEATURE_FLAG mutation', (done) => { - testAction( + it('should commit REQUEST_UPDATE_FEATURE_FLAG mutation', () => { + return testAction( requestUpdateFeatureFlag, null, mockedState, [{ type: types.REQUEST_UPDATE_FEATURE_FLAG }], [], - done, ); }); }); describe('receiveUpdateFeatureFlagSuccess', () => { - it('should commit RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS mutation', () => { + return testAction( receiveUpdateFeatureFlagSuccess, null, mockedState, @@ -128,20 +125,18 @@ describe('Feature flags Edit Module actions', () => { }, ], [], - done, ); }); }); describe('receiveUpdateFeatureFlagError', () => { - it('should commit RECEIVE_UPDATE_FEATURE_FLAG_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_UPDATE_FEATURE_FLAG_ERROR mutation', () => { + return testAction( receiveUpdateFeatureFlagError, 'There was an error', mockedState, [{ type: types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR, payload: 'There was an error' }], [], - done, ); }); }); @@ -159,10 +154,10 @@ describe('Feature flags Edit Module actions', () => { }); describe('success', () => { - it('dispatches requestFeatureFlag and receiveFeatureFlagSuccess ', (done) => { + it('dispatches requestFeatureFlag and receiveFeatureFlagSuccess ', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, { id: 1 }); - testAction( + return testAction( fetchFeatureFlag, { id: 1 }, mockedState, @@ -176,16 +171,15 @@ describe('Feature flags Edit Module actions', () => { payload: { id: 1 }, }, ], - done, ); }); }); describe('error', () => { - it('dispatches requestFeatureFlag and receiveUpdateFeatureFlagError ', (done) => { + it('dispatches requestFeatureFlag and receiveUpdateFeatureFlagError ', () => { mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {}); - testAction( + return testAction( fetchFeatureFlag, null, mockedState, @@ -198,41 +192,38 @@ describe('Feature flags Edit Module actions', () => { type: 'receiveFeatureFlagError', }, ], - done, ); }); }); }); describe('requestFeatureFlag', () => { - it('should commit REQUEST_FEATURE_FLAG mutation', (done) => { - testAction( + it('should commit REQUEST_FEATURE_FLAG mutation', () => { + return testAction( requestFeatureFlag, null, mockedState, [{ type: types.REQUEST_FEATURE_FLAG }], [], - done, ); }); }); describe('receiveFeatureFlagSuccess', () => { - it('should commit RECEIVE_FEATURE_FLAG_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_FEATURE_FLAG_SUCCESS mutation', () => { + return testAction( receiveFeatureFlagSuccess, { id: 1 }, mockedState, [{ type: types.RECEIVE_FEATURE_FLAG_SUCCESS, payload: { id: 1 } }], [], - done, ); }); }); describe('receiveFeatureFlagError', () => { - it('should commit RECEIVE_FEATURE_FLAG_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_FEATURE_FLAG_ERROR mutation', () => { + return testAction( receiveFeatureFlagError, null, mockedState, @@ -242,20 +233,18 @@ describe('Feature flags Edit Module actions', () => { }, ], [], - done, ); }); }); describe('toggelActive', () => { - it('should commit TOGGLE_ACTIVE mutation', (done) => { - testAction( + it('should commit TOGGLE_ACTIVE mutation', () => { + return testAction( toggleActive, true, mockedState, [{ type: types.TOGGLE_ACTIVE, payload: true }], [], - done, ); }); }); diff --git a/spec/frontend/feature_flags/store/index/actions_spec.js b/spec/frontend/feature_flags/store/index/actions_spec.js index a59f99f538c..ce62c3b0473 100644 --- a/spec/frontend/feature_flags/store/index/actions_spec.js +++ b/spec/frontend/feature_flags/store/index/actions_spec.js @@ -32,14 +32,13 @@ describe('Feature flags actions', () => { }); describe('setFeatureFlagsOptions', () => { - it('should commit SET_FEATURE_FLAGS_OPTIONS mutation', (done) => { - testAction( + it('should commit SET_FEATURE_FLAGS_OPTIONS mutation', () => { + return testAction( setFeatureFlagsOptions, { page: '1', scope: 'all' }, mockedState, [{ type: types.SET_FEATURE_FLAGS_OPTIONS, payload: { page: '1', scope: 'all' } }], [], - done, ); }); }); @@ -57,10 +56,10 @@ describe('Feature flags actions', () => { }); describe('success', () => { - it('dispatches requestFeatureFlags and receiveFeatureFlagsSuccess ', (done) => { + it('dispatches requestFeatureFlags and receiveFeatureFlagsSuccess ', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, getRequestData, {}); - testAction( + return testAction( fetchFeatureFlags, null, mockedState, @@ -74,16 +73,15 @@ describe('Feature flags actions', () => { type: 'receiveFeatureFlagsSuccess', }, ], - done, ); }); }); describe('error', () => { - it('dispatches requestFeatureFlags and receiveFeatureFlagsError ', (done) => { + it('dispatches requestFeatureFlags and receiveFeatureFlagsError ', () => { mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {}); - testAction( + return testAction( fetchFeatureFlags, null, mockedState, @@ -96,28 +94,26 @@ describe('Feature flags actions', () => { type: 'receiveFeatureFlagsError', }, ], - done, ); }); }); }); describe('requestFeatureFlags', () => { - it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', () => { + return testAction( requestFeatureFlags, null, mockedState, [{ type: types.REQUEST_FEATURE_FLAGS }], [], - done, ); }); }); describe('receiveFeatureFlagsSuccess', () => { - it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', () => { + return testAction( receiveFeatureFlagsSuccess, { data: getRequestData, headers: {} }, mockedState, @@ -128,20 +124,18 @@ describe('Feature flags actions', () => { }, ], [], - done, ); }); }); describe('receiveFeatureFlagsError', () => { - it('should commit RECEIVE_FEATURE_FLAGS_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_FEATURE_FLAGS_ERROR mutation', () => { + return testAction( receiveFeatureFlagsError, null, mockedState, [{ type: types.RECEIVE_FEATURE_FLAGS_ERROR }], [], - done, ); }); }); @@ -159,10 +153,10 @@ describe('Feature flags actions', () => { }); describe('success', () => { - it('dispatches requestRotateInstanceId and receiveRotateInstanceIdSuccess ', (done) => { + it('dispatches requestRotateInstanceId and receiveRotateInstanceIdSuccess ', () => { mock.onPost(`${TEST_HOST}/endpoint.json`).replyOnce(200, rotateData, {}); - testAction( + return testAction( rotateInstanceId, null, mockedState, @@ -176,16 +170,15 @@ describe('Feature flags actions', () => { type: 'receiveRotateInstanceIdSuccess', }, ], - done, ); }); }); describe('error', () => { - it('dispatches requestRotateInstanceId and receiveRotateInstanceIdError ', (done) => { + it('dispatches requestRotateInstanceId and receiveRotateInstanceIdError ', () => { mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {}); - testAction( + return testAction( rotateInstanceId, null, mockedState, @@ -198,28 +191,26 @@ describe('Feature flags actions', () => { type: 'receiveRotateInstanceIdError', }, ], - done, ); }); }); }); describe('requestRotateInstanceId', () => { - it('should commit REQUEST_ROTATE_INSTANCE_ID mutation', (done) => { - testAction( + it('should commit REQUEST_ROTATE_INSTANCE_ID mutation', () => { + return testAction( requestRotateInstanceId, null, mockedState, [{ type: types.REQUEST_ROTATE_INSTANCE_ID }], [], - done, ); }); }); describe('receiveRotateInstanceIdSuccess', () => { - it('should commit RECEIVE_ROTATE_INSTANCE_ID_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_ROTATE_INSTANCE_ID_SUCCESS mutation', () => { + return testAction( receiveRotateInstanceIdSuccess, { data: rotateData, headers: {} }, mockedState, @@ -230,20 +221,18 @@ describe('Feature flags actions', () => { }, ], [], - done, ); }); }); describe('receiveRotateInstanceIdError', () => { - it('should commit RECEIVE_ROTATE_INSTANCE_ID_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_ROTATE_INSTANCE_ID_ERROR mutation', () => { + return testAction( receiveRotateInstanceIdError, null, mockedState, [{ type: types.RECEIVE_ROTATE_INSTANCE_ID_ERROR }], [], - done, ); }); }); @@ -262,10 +251,10 @@ describe('Feature flags actions', () => { mock.restore(); }); describe('success', () => { - it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', (done) => { + it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', () => { mock.onPut(featureFlag.update_path).replyOnce(200, featureFlag, {}); - testAction( + return testAction( toggleFeatureFlag, featureFlag, mockedState, @@ -280,15 +269,15 @@ describe('Feature flags actions', () => { type: 'receiveUpdateFeatureFlagSuccess', }, ], - done, ); }); }); + describe('error', () => { - it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', (done) => { + it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', () => { mock.onPut(featureFlag.update_path).replyOnce(500); - testAction( + return testAction( toggleFeatureFlag, featureFlag, mockedState, @@ -303,7 +292,6 @@ describe('Feature flags actions', () => { type: 'receiveUpdateFeatureFlagError', }, ], - done, ); }); }); @@ -315,8 +303,8 @@ describe('Feature flags actions', () => { })); }); - it('commits UPDATE_FEATURE_FLAG with the given flag', (done) => { - testAction( + it('commits UPDATE_FEATURE_FLAG with the given flag', () => { + return testAction( updateFeatureFlag, featureFlag, mockedState, @@ -327,7 +315,6 @@ describe('Feature flags actions', () => { }, ], [], - done, ); }); }); @@ -338,8 +325,8 @@ describe('Feature flags actions', () => { })); }); - it('commits RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS with the given flag', (done) => { - testAction( + it('commits RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS with the given flag', () => { + return testAction( receiveUpdateFeatureFlagSuccess, featureFlag, mockedState, @@ -350,7 +337,6 @@ describe('Feature flags actions', () => { }, ], [], - done, ); }); }); @@ -361,8 +347,8 @@ describe('Feature flags actions', () => { })); }); - it('commits RECEIVE_UPDATE_FEATURE_FLAG_ERROR with the given flag id', (done) => { - testAction( + it('commits RECEIVE_UPDATE_FEATURE_FLAG_ERROR with the given flag id', () => { + return testAction( receiveUpdateFeatureFlagError, featureFlag.id, mockedState, @@ -373,22 +359,20 @@ describe('Feature flags actions', () => { }, ], [], - done, ); }); }); describe('clearAlert', () => { - it('should commit RECEIVE_CLEAR_ALERT', (done) => { + it('should commit RECEIVE_CLEAR_ALERT', () => { const alertIndex = 3; - testAction( + return testAction( clearAlert, alertIndex, mockedState, [{ type: 'RECEIVE_CLEAR_ALERT', payload: alertIndex }], [], - done, ); }); }); diff --git a/spec/frontend/feature_flags/store/new/actions_spec.js b/spec/frontend/feature_flags/store/new/actions_spec.js index 7900b200eb2..1dcd2da1d93 100644 --- a/spec/frontend/feature_flags/store/new/actions_spec.js +++ b/spec/frontend/feature_flags/store/new/actions_spec.js @@ -33,7 +33,7 @@ describe('Feature flags New Module Actions', () => { }); describe('success', () => { - it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagSuccess ', (done) => { + it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagSuccess ', () => { const actionParams = { name: 'name', description: 'description', @@ -50,7 +50,7 @@ describe('Feature flags New Module Actions', () => { }; mock.onPost(mockedState.endpoint, mapStrategiesToRails(actionParams)).replyOnce(200); - testAction( + return testAction( createFeatureFlag, actionParams, mockedState, @@ -63,13 +63,12 @@ describe('Feature flags New Module Actions', () => { type: 'receiveCreateFeatureFlagSuccess', }, ], - done, ); }); }); describe('error', () => { - it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagError ', (done) => { + it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagError ', () => { const actionParams = { name: 'name', description: 'description', @@ -88,7 +87,7 @@ describe('Feature flags New Module Actions', () => { .onPost(mockedState.endpoint, mapStrategiesToRails(actionParams)) .replyOnce(500, { message: [] }); - testAction( + return testAction( createFeatureFlag, actionParams, mockedState, @@ -102,28 +101,26 @@ describe('Feature flags New Module Actions', () => { payload: { message: [] }, }, ], - done, ); }); }); }); describe('requestCreateFeatureFlag', () => { - it('should commit REQUEST_CREATE_FEATURE_FLAG mutation', (done) => { - testAction( + it('should commit REQUEST_CREATE_FEATURE_FLAG mutation', () => { + return testAction( requestCreateFeatureFlag, null, mockedState, [{ type: types.REQUEST_CREATE_FEATURE_FLAG }], [], - done, ); }); }); describe('receiveCreateFeatureFlagSuccess', () => { - it('should commit RECEIVE_CREATE_FEATURE_FLAG_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_CREATE_FEATURE_FLAG_SUCCESS mutation', () => { + return testAction( receiveCreateFeatureFlagSuccess, null, mockedState, @@ -133,20 +130,18 @@ describe('Feature flags New Module Actions', () => { }, ], [], - done, ); }); }); describe('receiveCreateFeatureFlagError', () => { - it('should commit RECEIVE_CREATE_FEATURE_FLAG_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_CREATE_FEATURE_FLAG_ERROR mutation', () => { + return testAction( receiveCreateFeatureFlagError, 'There was an error', mockedState, [{ type: types.RECEIVE_CREATE_FEATURE_FLAG_ERROR, payload: 'There was an error' }], [], - done, ); }); }); diff --git a/spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js b/spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js index 88b3fc236e4..212b9ffc8f9 100644 --- a/spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js +++ b/spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js @@ -38,35 +38,25 @@ describe('AjaxFilter', () => { dummyList.list.appendChild(dynamicList); }); - it('calls onLoadingFinished after loading data', (done) => { + it('calls onLoadingFinished after loading data', async () => { ajaxSpy = (url) => { expect(url).toBe('dummy endpoint?dummy search key='); return Promise.resolve(dummyData); }; - AjaxFilter.trigger() - .then(() => { - expect(dummyConfig.onLoadingFinished.mock.calls.length).toBe(1); - }) - .then(done) - .catch(done.fail); + await AjaxFilter.trigger(); + expect(dummyConfig.onLoadingFinished.mock.calls.length).toBe(1); }); - it('does not call onLoadingFinished if Ajax call fails', (done) => { + it('does not call onLoadingFinished if Ajax call fails', async () => { const dummyError = new Error('My dummy is sick! :-('); ajaxSpy = (url) => { expect(url).toBe('dummy endpoint?dummy search key='); return Promise.reject(dummyError); }; - AjaxFilter.trigger() - .then(done.fail) - .catch((error) => { - expect(error).toBe(dummyError); - expect(dummyConfig.onLoadingFinished.mock.calls.length).toBe(0); - }) - .then(done) - .catch(done.fail); + await expect(AjaxFilter.trigger()).rejects.toEqual(dummyError); + expect(dummyConfig.onLoadingFinished.mock.calls.length).toBe(0); }); }); }); diff --git a/spec/frontend/filtered_search/filtered_search_manager_spec.js b/spec/frontend/filtered_search/filtered_search_manager_spec.js index 83e7f6c9b3f..911a507af4c 100644 --- a/spec/frontend/filtered_search/filtered_search_manager_spec.js +++ b/spec/frontend/filtered_search/filtered_search_manager_spec.js @@ -190,43 +190,40 @@ describe('Filtered Search Manager', () => { const defaultParams = '?scope=all'; const defaultState = '&state=opened'; - it('should search with a single word', (done) => { + it('should search with a single word', () => { initializeManager(); input.value = 'searchTerm'; visitUrl.mockImplementation((url) => { expect(url).toEqual(`${defaultParams}&search=searchTerm`); - done(); }); manager.search(); }); - it('sets default state', (done) => { + it('sets default state', () => { initializeManager({ useDefaultState: true }); input.value = 'searchTerm'; visitUrl.mockImplementation((url) => { expect(url).toEqual(`${defaultParams}${defaultState}&search=searchTerm`); - done(); }); manager.search(); }); - it('should search with multiple words', (done) => { + it('should search with multiple words', () => { initializeManager(); input.value = 'awesome search terms'; visitUrl.mockImplementation((url) => { expect(url).toEqual(`${defaultParams}&search=awesome+search+terms`); - done(); }); manager.search(); }); - it('should search with special characters', (done) => { + it('should search with special characters', () => { initializeManager(); input.value = '~!@#$%^&*()_+{}:<>,.?/'; @@ -234,13 +231,12 @@ describe('Filtered Search Manager', () => { expect(url).toEqual( `${defaultParams}&search=~!%40%23%24%25%5E%26*()_%2B%7B%7D%3A%3C%3E%2C.%3F%2F`, ); - done(); }); manager.search(); }); - it('should use replacement URL for condition', (done) => { + it('should use replacement URL for condition', () => { initializeManager(); tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', '13', true), @@ -248,7 +244,6 @@ describe('Filtered Search Manager', () => { visitUrl.mockImplementation((url) => { expect(url).toEqual(`${defaultParams}&milestone_title=replaced`); - done(); }); manager.filteredSearchTokenKeys.conditions.push({ @@ -261,7 +256,7 @@ describe('Filtered Search Manager', () => { manager.search(); }); - it('removes duplicated tokens', (done) => { + it('removes duplicated tokens', () => { initializeManager(); tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')} @@ -270,7 +265,6 @@ describe('Filtered Search Manager', () => { visitUrl.mockImplementation((url) => { expect(url).toEqual(`${defaultParams}&label_name[]=bug`); - done(); }); manager.search(); diff --git a/spec/frontend/filtered_search/services/recent_searches_service_spec.js b/spec/frontend/filtered_search/services/recent_searches_service_spec.js index dfa53652eb1..426a60df427 100644 --- a/spec/frontend/filtered_search/services/recent_searches_service_spec.js +++ b/spec/frontend/filtered_search/services/recent_searches_service_spec.js @@ -18,53 +18,47 @@ describe('RecentSearchesService', () => { jest.spyOn(RecentSearchesService, 'isAvailable').mockReturnValue(true); }); - it('should default to empty array', (done) => { + it('should default to empty array', () => { const fetchItemsPromise = service.fetch(); - fetchItemsPromise - .then((items) => { - expect(items).toEqual([]); - }) - .then(done) - .catch(done.fail); + return fetchItemsPromise.then((items) => { + expect(items).toEqual([]); + }); }); - it('should reject when unable to parse', (done) => { + it('should reject when unable to parse', () => { jest.spyOn(localStorage, 'getItem').mockReturnValue('fail'); const fetchItemsPromise = service.fetch(); - fetchItemsPromise - .then(done.fail) + return fetchItemsPromise + .then(() => { + throw new Error(); + }) .catch((error) => { expect(error).toEqual(expect.any(SyntaxError)); - }) - .then(done) - .catch(done.fail); + }); }); - it('should reject when service is unavailable', (done) => { + it('should reject when service is unavailable', () => { RecentSearchesService.isAvailable.mockReturnValue(false); - service + return service .fetch() - .then(done.fail) + .then(() => { + throw new Error(); + }) .catch((error) => { expect(error).toEqual(expect.any(Error)); - }) - .then(done) - .catch(done.fail); + }); }); - it('should return items from localStorage', (done) => { + it('should return items from localStorage', () => { jest.spyOn(localStorage, 'getItem').mockReturnValue('["foo", "bar"]'); const fetchItemsPromise = service.fetch(); - fetchItemsPromise - .then((items) => { - expect(items).toEqual(['foo', 'bar']); - }) - .then(done) - .catch(done.fail); + return fetchItemsPromise.then((items) => { + expect(items).toEqual(['foo', 'bar']); + }); }); describe('if .isAvailable returns `false`', () => { @@ -74,16 +68,16 @@ describe('RecentSearchesService', () => { jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {}); }); - it('should not call .getItem', (done) => { - RecentSearchesService.prototype + it('should not call .getItem', () => { + return RecentSearchesService.prototype .fetch() - .then(done.fail) + .then(() => { + throw new Error(); + }) .catch((err) => { expect(err).toEqual(new RecentSearchesServiceError()); expect(localStorage.getItem).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + }); }); }); }); diff --git a/spec/frontend/filtered_search/visual_token_value_spec.js b/spec/frontend/filtered_search/visual_token_value_spec.js index 8ac5b6fbea6..bf526a8d371 100644 --- a/spec/frontend/filtered_search/visual_token_value_spec.js +++ b/spec/frontend/filtered_search/visual_token_value_spec.js @@ -46,7 +46,7 @@ describe('Filtered Search Visual Tokens', () => { jest.spyOn(UsersCache, 'retrieve').mockImplementation((username) => usersCacheSpy(username)); }); - it('ignores error if UsersCache throws', (done) => { + it('ignores error if UsersCache throws', async () => { const dummyError = new Error('Earth rotated backwards'); const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); const tokenValue = tokenValueElement.innerText; @@ -55,16 +55,11 @@ describe('Filtered Search Visual Tokens', () => { return Promise.reject(dummyError); }; - subject - .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) - .then(() => { - expect(createFlash.mock.calls.length).toBe(0); - }) - .then(done) - .catch(done.fail); + await subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue); + expect(createFlash.mock.calls.length).toBe(0); }); - it('does nothing if user cannot be found', (done) => { + it('does nothing if user cannot be found', async () => { const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); const tokenValue = tokenValueElement.innerText; usersCacheSpy = (username) => { @@ -72,16 +67,11 @@ describe('Filtered Search Visual Tokens', () => { return Promise.resolve(undefined); }; - subject - .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) - .then(() => { - expect(tokenValueElement.innerText).toBe(tokenValue); - }) - .then(done) - .catch(done.fail); + await subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue); + expect(tokenValueElement.innerText).toBe(tokenValue); }); - it('replaces author token with avatar and display name', (done) => { + it('replaces author token with avatar and display name', async () => { const dummyUser = { name: 'Important Person', avatar_url: 'https://host.invalid/mypics/avatar.png', @@ -93,21 +83,16 @@ describe('Filtered Search Visual Tokens', () => { return Promise.resolve(dummyUser); }; - subject - .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) - .then(() => { - expect(tokenValueContainer.dataset.originalValue).toBe(tokenValue); - expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name); - const avatar = tokenValueElement.querySelector('img.avatar'); - - expect(avatar.getAttribute('src')).toBe(dummyUser.avatar_url); - expect(avatar.getAttribute('alt')).toBe(''); - }) - .then(done) - .catch(done.fail); + await subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue); + expect(tokenValueContainer.dataset.originalValue).toBe(tokenValue); + expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name); + const avatar = tokenValueElement.querySelector('img.avatar'); + + expect(avatar.getAttribute('src')).toBe(dummyUser.avatar_url); + expect(avatar.getAttribute('alt')).toBe(''); }); - it('escapes user name when creating token', (done) => { + it('escapes user name when creating token', async () => { const dummyUser = { name: '<script>', avatar_url: `${TEST_HOST}/mypics/avatar.png`, @@ -119,16 +104,11 @@ describe('Filtered Search Visual Tokens', () => { return Promise.resolve(dummyUser); }; - subject - .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) - .then(() => { - expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name); - tokenValueElement.querySelector('.avatar').remove(); + await subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue); + expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name); + tokenValueElement.querySelector('.avatar').remove(); - expect(tokenValueElement.innerHTML.trim()).toBe(escape(dummyUser.name)); - }) - .then(done) - .catch(done.fail); + expect(tokenValueElement.innerHTML.trim()).toBe(escape(dummyUser.name)); }); }); @@ -177,48 +157,33 @@ describe('Filtered Search Visual Tokens', () => { const findLabel = (tokenValue) => labelData.find((label) => tokenValue === `~${DropdownUtils.getEscapedText(label.title)}`); - it('updates the color of a label token', (done) => { + it('updates the color of a label token', async () => { const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken); const tokenValue = tokenValueElement.innerText; const matchingLabel = findLabel(tokenValue); - subject - .updateLabelTokenColor(tokenValueContainer, tokenValue) - .then(() => { - expectValueContainerStyle(tokenValueContainer, matchingLabel); - }) - .then(done) - .catch(done.fail); + await subject.updateLabelTokenColor(tokenValueContainer, tokenValue); + expectValueContainerStyle(tokenValueContainer, matchingLabel); }); - it('updates the color of a label token with spaces', (done) => { + it('updates the color of a label token with spaces', async () => { const { subject, tokenValueContainer, tokenValueElement } = findElements(spaceLabelToken); const tokenValue = tokenValueElement.innerText; const matchingLabel = findLabel(tokenValue); - subject - .updateLabelTokenColor(tokenValueContainer, tokenValue) - .then(() => { - expectValueContainerStyle(tokenValueContainer, matchingLabel); - }) - .then(done) - .catch(done.fail); + await subject.updateLabelTokenColor(tokenValueContainer, tokenValue); + expectValueContainerStyle(tokenValueContainer, matchingLabel); }); - it('does not change color of a missing label', (done) => { + it('does not change color of a missing label', async () => { const { subject, tokenValueContainer, tokenValueElement } = findElements(missingLabelToken); const tokenValue = tokenValueElement.innerText; const matchingLabel = findLabel(tokenValue); expect(matchingLabel).toBe(undefined); - subject - .updateLabelTokenColor(tokenValueContainer, tokenValue) - .then(() => { - expect(tokenValueContainer.getAttribute('style')).toBe(null); - }) - .then(done) - .catch(done.fail); + await subject.updateLabelTokenColor(tokenValueContainer, tokenValue); + expect(tokenValueContainer.getAttribute('style')).toBe(null); }); }); diff --git a/spec/frontend/fixtures/startup_css.rb b/spec/frontend/fixtures/startup_css.rb index e19a98c3bab..cf7383fa6ca 100644 --- a/spec/frontend/fixtures/startup_css.rb +++ b/spec/frontend/fixtures/startup_css.rb @@ -41,12 +41,12 @@ RSpec.describe 'Startup CSS fixtures', type: :controller do expect(response).to be_successful end - # This Feature Flag is off by default + # This Feature Flag is on by default # This ensures that the correct css is generated - # When the feature flag is off, the general startup will capture it + # When the feature flag is on, the general startup will capture it # This will be removed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/339348 - it "startup_css/project-#{type}-search-ff-on.html" do - stub_feature_flags(new_header_search: true) + it "startup_css/project-#{type}-search-ff-off.html" do + stub_feature_flags(new_header_search: false) get :show, params: { namespace_id: project.namespace.to_param, diff --git a/spec/frontend/frequent_items/store/actions_spec.js b/spec/frontend/frequent_items/store/actions_spec.js index fb0321545c2..3fc3eaf52a2 100644 --- a/spec/frontend/frequent_items/store/actions_spec.js +++ b/spec/frontend/frequent_items/store/actions_spec.js @@ -29,136 +29,126 @@ describe('Frequent Items Dropdown Store Actions', () => { }); describe('setNamespace', () => { - it('should set namespace', (done) => { - testAction( + it('should set namespace', () => { + return testAction( actions.setNamespace, mockNamespace, mockedState, [{ type: types.SET_NAMESPACE, payload: mockNamespace }], [], - done, ); }); }); describe('setStorageKey', () => { - it('should set storage key', (done) => { - testAction( + it('should set storage key', () => { + return testAction( actions.setStorageKey, mockStorageKey, mockedState, [{ type: types.SET_STORAGE_KEY, payload: mockStorageKey }], [], - done, ); }); }); describe('requestFrequentItems', () => { - it('should request frequent items', (done) => { - testAction( + it('should request frequent items', () => { + return testAction( actions.requestFrequentItems, null, mockedState, [{ type: types.REQUEST_FREQUENT_ITEMS }], [], - done, ); }); }); describe('receiveFrequentItemsSuccess', () => { - it('should set frequent items', (done) => { - testAction( + it('should set frequent items', () => { + return testAction( actions.receiveFrequentItemsSuccess, mockFrequentProjects, mockedState, [{ type: types.RECEIVE_FREQUENT_ITEMS_SUCCESS, payload: mockFrequentProjects }], [], - done, ); }); }); describe('receiveFrequentItemsError', () => { - it('should set frequent items error state', (done) => { - testAction( + it('should set frequent items error state', () => { + return testAction( actions.receiveFrequentItemsError, null, mockedState, [{ type: types.RECEIVE_FREQUENT_ITEMS_ERROR }], [], - done, ); }); }); describe('fetchFrequentItems', () => { - it('should dispatch `receiveFrequentItemsSuccess`', (done) => { + it('should dispatch `receiveFrequentItemsSuccess`', () => { mockedState.namespace = mockNamespace; mockedState.storageKey = mockStorageKey; - testAction( + return testAction( actions.fetchFrequentItems, null, mockedState, [], [{ type: 'requestFrequentItems' }, { type: 'receiveFrequentItemsSuccess', payload: [] }], - done, ); }); - it('should dispatch `receiveFrequentItemsError`', (done) => { + it('should dispatch `receiveFrequentItemsError`', () => { jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false); mockedState.namespace = mockNamespace; mockedState.storageKey = mockStorageKey; - testAction( + return testAction( actions.fetchFrequentItems, null, mockedState, [], [{ type: 'requestFrequentItems' }, { type: 'receiveFrequentItemsError' }], - done, ); }); }); describe('requestSearchedItems', () => { - it('should request searched items', (done) => { - testAction( + it('should request searched items', () => { + return testAction( actions.requestSearchedItems, null, mockedState, [{ type: types.REQUEST_SEARCHED_ITEMS }], [], - done, ); }); }); describe('receiveSearchedItemsSuccess', () => { - it('should set searched items', (done) => { - testAction( + it('should set searched items', () => { + return testAction( actions.receiveSearchedItemsSuccess, mockSearchedProjects, mockedState, [{ type: types.RECEIVE_SEARCHED_ITEMS_SUCCESS, payload: mockSearchedProjects }], [], - done, ); }); }); describe('receiveSearchedItemsError', () => { - it('should set searched items error state', (done) => { - testAction( + it('should set searched items error state', () => { + return testAction( actions.receiveSearchedItemsError, null, mockedState, [{ type: types.RECEIVE_SEARCHED_ITEMS_ERROR }], [], - done, ); }); }); @@ -168,10 +158,10 @@ describe('Frequent Items Dropdown Store Actions', () => { gon.api_version = 'v4'; }); - it('should dispatch `receiveSearchedItemsSuccess`', (done) => { + it('should dispatch `receiveSearchedItemsSuccess`', () => { mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects, {}); - testAction( + return testAction( actions.fetchSearchedItems, null, mockedState, @@ -183,45 +173,41 @@ describe('Frequent Items Dropdown Store Actions', () => { payload: { data: mockSearchedProjects, headers: {} }, }, ], - done, ); }); - it('should dispatch `receiveSearchedItemsError`', (done) => { + it('should dispatch `receiveSearchedItemsError`', () => { gon.api_version = 'v4'; mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(500); - testAction( + return testAction( actions.fetchSearchedItems, null, mockedState, [], [{ type: 'requestSearchedItems' }, { type: 'receiveSearchedItemsError' }], - done, ); }); }); describe('setSearchQuery', () => { - it('should commit query and dispatch `fetchSearchedItems` when query is present', (done) => { - testAction( + it('should commit query and dispatch `fetchSearchedItems` when query is present', () => { + return testAction( actions.setSearchQuery, { query: 'test' }, mockedState, [{ type: types.SET_SEARCH_QUERY, payload: { query: 'test' } }], [{ type: 'fetchSearchedItems', payload: { query: 'test' } }], - done, ); }); - it('should commit query and dispatch `fetchFrequentItems` when query is empty', (done) => { - testAction( + it('should commit query and dispatch `fetchFrequentItems` when query is empty', () => { + return testAction( actions.setSearchQuery, null, mockedState, [{ type: types.SET_SEARCH_QUERY, payload: null }], [{ type: 'fetchFrequentItems' }], - done, ); }); }); diff --git a/spec/frontend/google_cloud/components/app_spec.js b/spec/frontend/google_cloud/components/app_spec.js index 50b05fb30e0..0cafe6d3b9d 100644 --- a/spec/frontend/google_cloud/components/app_spec.js +++ b/spec/frontend/google_cloud/components/app_spec.js @@ -8,7 +8,7 @@ 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/meta/-/issues/new'; + 'https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new'; const SCREEN_COMPONENTS = { Home, ServiceAccountsForm, diff --git a/spec/frontend/gpg_badges_spec.js b/spec/frontend/gpg_badges_spec.js index 44c70f1ad4d..0bb50fc3e6f 100644 --- a/spec/frontend/gpg_badges_spec.js +++ b/spec/frontend/gpg_badges_spec.js @@ -40,30 +40,22 @@ describe('GpgBadges', () => { mock.restore(); }); - it('does not make a request if there is no container element', (done) => { + it('does not make a request if there is no container element', async () => { setFixtures(''); jest.spyOn(axios, 'get').mockImplementation(() => {}); - GpgBadges.fetch() - .then(() => { - expect(axios.get).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + await GpgBadges.fetch(); + expect(axios.get).not.toHaveBeenCalled(); }); - it('throws an error if the endpoint is missing', (done) => { + it('throws an error if the endpoint is missing', async () => { setFixtures('<div class="js-signature-container"></div>'); jest.spyOn(axios, 'get').mockImplementation(() => {}); - GpgBadges.fetch() - .then(() => done.fail('Expected error to be thrown')) - .catch((error) => { - expect(error.message).toBe('Missing commit signatures endpoint!'); - expect(axios.get).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + await expect(GpgBadges.fetch()).rejects.toEqual( + new Error('Missing commit signatures endpoint!'), + ); + expect(axios.get).not.toHaveBeenCalled(); }); it('fetches commit signatures', async () => { @@ -104,31 +96,23 @@ describe('GpgBadges', () => { }); }); - it('displays a loading spinner', (done) => { + it('displays a loading spinner', async () => { mock.onGet(dummyUrl).replyOnce(200); - GpgBadges.fetch() - .then(() => { - expect(document.querySelector('.js-loading-gpg-badge:empty')).toBe(null); - const spinners = document.querySelectorAll('.js-loading-gpg-badge span.gl-spinner'); + await GpgBadges.fetch(); + expect(document.querySelector('.js-loading-gpg-badge:empty')).toBe(null); + const spinners = document.querySelectorAll('.js-loading-gpg-badge span.gl-spinner'); - expect(spinners.length).toBe(1); - done(); - }) - .catch(done.fail); + expect(spinners.length).toBe(1); }); - it('replaces the loading spinner', (done) => { + it('replaces the loading spinner', async () => { mock.onGet(dummyUrl).replyOnce(200, dummyResponse); - GpgBadges.fetch() - .then(() => { - expect(document.querySelector('.js-loading-gpg-badge')).toBe(null); - const parentContainer = document.querySelector('.parent-container'); + await GpgBadges.fetch(); + expect(document.querySelector('.js-loading-gpg-badge')).toBe(null); + const parentContainer = document.querySelector('.parent-container'); - expect(parentContainer.innerHTML.trim()).toEqual(dummyBadgeHtml); - done(); - }) - .catch(done.fail); + expect(parentContainer.innerHTML.trim()).toEqual(dummyBadgeHtml); }); }); diff --git a/spec/frontend/groups/components/item_type_icon_spec.js b/spec/frontend/groups/components/item_type_icon_spec.js index 9310943841e..f3652f1a410 100644 --- a/spec/frontend/groups/components/item_type_icon_spec.js +++ b/spec/frontend/groups/components/item_type_icon_spec.js @@ -8,7 +8,6 @@ describe('ItemTypeIcon', () => { const defaultProps = { itemType: ITEM_TYPE.GROUP, - isGroupOpen: false, }; const createComponent = (props = {}) => { @@ -34,20 +33,14 @@ describe('ItemTypeIcon', () => { }); it.each` - type | isGroupOpen | icon - ${ITEM_TYPE.GROUP} | ${true} | ${'folder-open'} - ${ITEM_TYPE.GROUP} | ${false} | ${'folder-o'} - ${ITEM_TYPE.PROJECT} | ${true} | ${'bookmark'} - ${ITEM_TYPE.PROJECT} | ${false} | ${'bookmark'} - `( - 'shows "$icon" icon when `itemType` is "$type" and `isGroupOpen` is $isGroupOpen', - ({ type, isGroupOpen, icon }) => { - createComponent({ - itemType: type, - isGroupOpen, - }); - expect(findGlIcon().props('name')).toBe(icon); - }, - ); + type | icon + ${ITEM_TYPE.GROUP} | ${'subgroup'} + ${ITEM_TYPE.PROJECT} | ${'project'} + `('shows "$icon" icon when `itemType` is "$type"', ({ type, icon }) => { + createComponent({ + itemType: type, + }); + expect(findGlIcon().props('name')).toBe(icon); + }); }); }); diff --git a/spec/frontend/header_search/components/app_spec.js b/spec/frontend/header_search/components/app_spec.js index dcbeeeffb2d..f0de5b083ae 100644 --- a/spec/frontend/header_search/components/app_spec.js +++ b/spec/frontend/header_search/components/app_spec.js @@ -16,6 +16,7 @@ import { MOCK_USERNAME, MOCK_DEFAULT_SEARCH_OPTIONS, MOCK_SCOPED_SEARCH_OPTIONS, + MOCK_SORTED_AUTOCOMPLETE_OPTIONS, } from '../mock_data'; Vue.use(Vuex); @@ -108,6 +109,11 @@ describe('HeaderSearchApp', () => { 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', @@ -115,7 +121,13 @@ describe('HeaderSearchApp', () => { describe(`when search is ${search}`, () => { beforeEach(() => { window.gon.current_username = MOCK_USERNAME; - createComponent({ search }); + createComponent( + { search }, + { + autocompleteGroupedSearchOptions: () => + search.match(/^[A-Za-z]+$/g) ? MOCK_SORTED_AUTOCOMPLETE_OPTIONS : [], + }, + ); findHeaderSearchInput().vm.$emit('click'); }); diff --git a/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js b/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js index f427482be46..7952661e2d2 100644 --- a/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js +++ b/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js @@ -1,4 +1,4 @@ -import { GlDropdownItem, GlLoadingIcon, GlAvatar, GlAlert } from '@gitlab/ui'; +import { GlDropdownItem, GlLoadingIcon, GlAvatar, GlAlert, GlDropdownDivider } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; @@ -8,8 +8,18 @@ import { LARGE_AVATAR_PX, PROJECTS_CATEGORY, SMALL_AVATAR_PX, + ISSUES_CATEGORY, + MERGE_REQUEST_CATEGORY, + RECENT_EPICS_CATEGORY, } from '~/header_search/constants'; -import { MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, MOCK_SORTED_AUTOCOMPLETE_OPTIONS } from '../mock_data'; +import { + MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, + MOCK_SORTED_AUTOCOMPLETE_OPTIONS, + MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_SETTINGS_HELP, + MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP, + MOCK_SEARCH, + MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2, +} from '../mock_data'; Vue.use(Vuex); @@ -41,8 +51,14 @@ describe('HeaderSearchAutocompleteItems', () => { }); const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findGlDropdownDividers = () => wrapper.findAllComponents(GlDropdownDivider); const findFirstDropdownItem = () => findDropdownItems().at(0); - const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => w.text()); + const findDropdownItemTitles = () => + findDropdownItems().wrappers.map((w) => w.findAll('span').at(1).text()); + const findDropdownItemSubTitles = () => + findDropdownItems() + .wrappers.filter((w) => w.findAll('span').length > 2) + .map((w) => w.findAll('span').at(2).text()); const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findGlAvatar = () => wrapper.findComponent(GlAvatar); @@ -87,10 +103,17 @@ describe('HeaderSearchAutocompleteItems', () => { }); it('renders titles correctly', () => { - const expectedTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.label); + const expectedTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.value || o.label); expect(findDropdownItemTitles()).toStrictEqual(expectedTitles); }); + it('renders sub-titles correctly', () => { + const expectedSubTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.filter((o) => o.value).map( + (o) => o.label, + ); + expect(findDropdownItemSubTitles()).toStrictEqual(expectedSubTitles); + }); + it('renders links correctly', () => { const expectedLinks = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.url); expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); @@ -98,15 +121,30 @@ describe('HeaderSearchAutocompleteItems', () => { }); describe.each` - item | showAvatar | avatarSize - ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} - ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: '/123' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} - ${{ data: [{ category: 'Help', avatar_url: '' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} - ${{ data: [{ category: 'Settings' }] }} | ${false} | ${false} - `('GlAvatar', ({ item, showAvatar, avatarSize }) => { + item | showAvatar | avatarSize | searchContext | entityId | entityName + ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 29 } }} | ${'29'} | ${''} + ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: '/123' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 12 } }} | ${'12'} | ${''} + ${{ data: [{ category: 'Help', avatar_url: '' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'0'} | ${''} + ${{ data: [{ category: 'Settings' }] }} | ${false} | ${false} | ${null} | ${false} | ${false} + ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 1, name: 'test1' } }} | ${'1'} | ${'test1'} + ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 2, name: 'test2' } }} | ${'2'} | ${'test2'} + ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 3, name: 'test3' } }} | ${'3'} | ${'test3'} + ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 4, name: 'test4' } }} | ${'4'} | ${'test4'} + ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ group: { id: 5, name: 'test5' } }} | ${'5'} | ${'test5'} + ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null, group_id: 6, group_name: 'test6' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${null} | ${'6'} | ${'test6'} + ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null, project_id: 7, project_name: 'test7' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${null} | ${'7'} | ${'test7'} + ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null, project_id: 8, project_name: 'test8' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'8'} | ${'test8'} + ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null, project_id: 9, project_name: 'test9' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'9'} | ${'test9'} + ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null, group_id: 10, group_name: 'test10' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'10'} | ${'test10'} + ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null, group_id: 11, group_name: 'test11' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 1, name: 'test1' } }} | ${'11'} | ${'test11'} + ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null, project_id: 12, project_name: 'test12' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 2, name: 'test2' } }} | ${'12'} | ${'test12'} + ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null, project_id: 13, project_name: 'test13' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 3, name: 'test3' } }} | ${'13'} | ${'test13'} + ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null, project_id: 14, project_name: 'test14' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 4, name: 'test4' } }} | ${'14'} | ${'test14'} + ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null, group_id: 15, group_name: 'test15' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ group: { id: 5, name: 'test5' } }} | ${'15'} | ${'test15'} + `('GlAvatar', ({ item, showAvatar, avatarSize, searchContext, entityId, entityName }) => { describe(`when category is ${item.data[0].category} and avatar_url is ${item.data[0].avatar_url}`, () => { beforeEach(() => { - createComponent({}, { autocompleteGroupedSearchOptions: () => [item] }); + createComponent({ searchContext }, { autocompleteGroupedSearchOptions: () => [item] }); }); it(`should${showAvatar ? '' : ' not'} render`, () => { @@ -116,6 +154,16 @@ describe('HeaderSearchAutocompleteItems', () => { it(`should set avatarSize to ${avatarSize}`, () => { expect(findGlAvatar().exists() && findGlAvatar().attributes('size')).toBe(avatarSize); }); + + it(`should set avatar entityId to ${entityId}`, () => { + expect(findGlAvatar().exists() && findGlAvatar().attributes('entityid')).toBe(entityId); + }); + + it(`should set avatar entityName to ${entityName}`, () => { + expect(findGlAvatar().exists() && findGlAvatar().attributes('entityname')).toBe( + entityName, + ); + }); }); }); }); @@ -140,6 +188,34 @@ describe('HeaderSearchAutocompleteItems', () => { }); }); }); + + describe.each` + search | items | dividerCount + ${null} | ${[]} | ${0} + ${''} | ${[]} | ${0} + ${'1'} | ${[]} | ${0} + ${')'} | ${[]} | ${0} + ${'t'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_SETTINGS_HELP} | ${1} + ${'te'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP} | ${0} + ${'tes'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2} | ${1} + ${MOCK_SEARCH} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2} | ${1} + `('Header Search Dropdown Dividers', ({ search, items, dividerCount }) => { + describe(`when search is ${search}`, () => { + beforeEach(() => { + createComponent( + { search }, + { + autocompleteGroupedSearchOptions: () => items, + }, + {}, + ); + }); + + it(`component should have ${dividerCount} dividers`, () => { + expect(findGlDropdownDividers()).toHaveLength(dividerCount); + }); + }); + }); }); describe('watchers', () => { 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 a65b4d8b813..8788fb23458 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,17 +1,21 @@ -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; import { trimText } from 'helpers/text_helper'; import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue'; -import { MOCK_SEARCH, MOCK_SCOPED_SEARCH_OPTIONS } from '../mock_data'; +import { + MOCK_SEARCH, + MOCK_SCOPED_SEARCH_OPTIONS, + MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, +} from '../mock_data'; Vue.use(Vuex); describe('HeaderSearchScopedItems', () => { let wrapper; - const createComponent = (initialState, props) => { + const createComponent = (initialState, mockGetters, props) => { const store = new Vuex.Store({ state: { search: MOCK_SEARCH, @@ -19,6 +23,8 @@ describe('HeaderSearchScopedItems', () => { }, getters: { scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS, + autocompleteGroupedSearchOptions: () => MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, + ...mockGetters, }, }); @@ -35,6 +41,7 @@ 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 findDropdownItemAriaLabels = () => @@ -79,7 +86,7 @@ describe('HeaderSearchScopedItems', () => { `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => { describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => { beforeEach(() => { - createComponent({}, { currentFocusedOption }); + createComponent({}, {}, { currentFocusedOption }); }); it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => { @@ -91,5 +98,21 @@ 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/mock_data.js b/spec/frontend/header_search/mock_data.js index 1d980679547..b6f0fdcc29d 100644 --- a/spec/frontend/header_search/mock_data.js +++ b/spec/frontend/header_search/mock_data.js @@ -96,19 +96,22 @@ export const MOCK_AUTOCOMPLETE_OPTIONS_RES = [ { category: 'Projects', id: 1, - label: 'MockProject1', + label: 'Gitlab Org / MockProject1', + value: 'MockProject1', url: 'project/1', }, { category: 'Groups', id: 1, - label: 'MockGroup1', + label: 'Gitlab Org / MockGroup1', + value: 'MockGroup1', url: 'group/1', }, { category: 'Projects', id: 2, - label: 'MockProject2', + label: 'Gitlab Org / MockProject2', + value: 'MockProject2', url: 'project/2', }, { @@ -123,21 +126,24 @@ export const MOCK_AUTOCOMPLETE_OPTIONS = [ category: 'Projects', html_id: 'autocomplete-Projects-0', id: 1, - label: 'MockProject1', + label: 'Gitlab Org / MockProject1', + value: 'MockProject1', url: 'project/1', }, { category: 'Groups', html_id: 'autocomplete-Groups-1', id: 1, - label: 'MockGroup1', + label: 'Gitlab Org / MockGroup1', + value: 'MockGroup1', url: 'group/1', }, { category: 'Projects', html_id: 'autocomplete-Projects-2', id: 2, - label: 'MockProject2', + label: 'Gitlab Org / MockProject2', + value: 'MockProject2', url: 'project/2', }, { @@ -157,7 +163,8 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [ html_id: 'autocomplete-Projects-0', id: 1, - label: 'MockProject1', + label: 'Gitlab Org / MockProject1', + value: 'MockProject1', url: 'project/1', }, { @@ -165,7 +172,8 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [ html_id: 'autocomplete-Projects-2', id: 2, - label: 'MockProject2', + label: 'Gitlab Org / MockProject2', + value: 'MockProject2', url: 'project/2', }, ], @@ -178,7 +186,8 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [ html_id: 'autocomplete-Groups-1', id: 1, - label: 'MockGroup1', + label: 'Gitlab Org / MockGroup1', + value: 'MockGroup1', url: 'group/1', }, ], @@ -202,21 +211,24 @@ export const MOCK_SORTED_AUTOCOMPLETE_OPTIONS = [ category: 'Projects', html_id: 'autocomplete-Projects-0', id: 1, - label: 'MockProject1', + label: 'Gitlab Org / MockProject1', + value: 'MockProject1', url: 'project/1', }, { category: 'Projects', html_id: 'autocomplete-Projects-2', id: 2, - label: 'MockProject2', + label: 'Gitlab Org / MockProject2', + value: 'MockProject2', url: 'project/2', }, { category: 'Groups', html_id: 'autocomplete-Groups-1', id: 1, - label: 'MockGroup1', + label: 'Gitlab Org / MockGroup1', + value: 'MockGroup1', url: 'group/1', }, { @@ -226,3 +238,98 @@ export const MOCK_SORTED_AUTOCOMPLETE_OPTIONS = [ url: 'help/gitlab', }, ]; + +export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP = [ + { + category: 'Help', + data: [ + { + html_id: 'autocomplete-Help-1', + category: 'Help', + label: 'Rake Tasks Help', + url: '/help/raketasks/index', + }, + { + html_id: 'autocomplete-Help-2', + category: 'Help', + label: 'System Hooks Help', + url: '/help/system_hooks/system_hooks', + }, + ], + }, +]; + +export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_SETTINGS_HELP = [ + { + category: 'Settings', + data: [ + { + html_id: 'autocomplete-Settings-0', + category: 'Settings', + label: 'User settings', + url: '/-/profile', + }, + { + html_id: 'autocomplete-Settings-3', + category: 'Settings', + label: 'Admin Section', + url: '/admin', + }, + ], + }, + { + category: 'Help', + data: [ + { + html_id: 'autocomplete-Help-1', + category: 'Help', + label: 'Rake Tasks Help', + url: '/help/raketasks/index', + }, + { + html_id: 'autocomplete-Help-2', + category: 'Help', + label: 'System Hooks Help', + url: '/help/system_hooks/system_hooks', + }, + ], + }, +]; + +export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2 = [ + { + category: 'Groups', + data: [ + { + html_id: 'autocomplete-Groups-0', + category: 'Groups', + id: 148, + label: 'Jashkenas / Test Subgroup / test-subgroup', + url: '/jashkenas/test-subgroup/test-subgroup', + avatar_url: '', + }, + { + html_id: 'autocomplete-Groups-1', + category: 'Groups', + id: 147, + label: 'Jashkenas / Test Subgroup', + url: '/jashkenas/test-subgroup', + avatar_url: '', + }, + ], + }, + { + category: 'Projects', + data: [ + { + html_id: 'autocomplete-Projects-2', + category: 'Projects', + id: 1, + value: 'Gitlab Test', + label: 'Gitlab Org / Gitlab Test', + url: '/gitlab-org/gitlab-test', + avatar_url: '/uploads/-/system/project/avatar/1/icons8-gitlab-512.png', + }, + ], + }, +]; diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js index 0d43accb7e5..937bc9aa478 100644 --- a/spec/frontend/header_spec.js +++ b/spec/frontend/header_spec.js @@ -60,7 +60,6 @@ describe('Header', () => { setFixtures(` <li class="js-nav-user-dropdown"> <a class="js-buy-pipeline-minutes-link" data-track-action="click_buy_ci_minutes" data-track-label="free" data-track-property="user_dropdown">Buy Pipeline minutes</a> - <a class="js-upgrade-plan-link" data-track-action="click_upgrade_link" data-track-label="free" data-track-property="user_dropdown">Upgrade</a> </li>`); trackingSpy = mockTracking('_category_', $('.js-nav-user-dropdown').element, jest.spyOn); @@ -81,14 +80,5 @@ describe('Header', () => { property: 'user_dropdown', }); }); - - it('sends a tracking event when the dropdown is opened and contains Upgrade link', () => { - $('.js-nav-user-dropdown').trigger('shown.bs.dropdown'); - - expect(trackingSpy).toHaveBeenCalledWith('some:page', 'show_upgrade_link', { - label: 'free', - property: 'user_dropdown', - }); - }); }); }); diff --git a/spec/frontend/ide/components/commit_sidebar/form_spec.js b/spec/frontend/ide/components/commit_sidebar/form_spec.js index d3b2923ac6c..28f62a9775a 100644 --- a/spec/frontend/ide/components/commit_sidebar/form_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/form_spec.js @@ -120,7 +120,7 @@ describe('IDE commit form', () => { it('renders commit button in compact mode', () => { expect(findBeginCommitButton().exists()).toBe(true); - expect(findBeginCommitButton().text()).toBe('Commit…'); + expect(findBeginCommitButton().text()).toBe('Create commit...'); }); it('does not render form', () => { diff --git a/spec/frontend/ide/components/ide_side_bar_spec.js b/spec/frontend/ide/components/ide_side_bar_spec.js index 34f14ef23a4..ace8988b8c9 100644 --- a/spec/frontend/ide/components/ide_side_bar_spec.js +++ b/spec/frontend/ide/components/ide_side_bar_spec.js @@ -1,4 +1,4 @@ -import { GlSkeletonLoading } from '@gitlab/ui'; +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; diff --git a/spec/frontend/ide/components/new_dropdown/upload_spec.js b/spec/frontend/ide/components/new_dropdown/upload_spec.js index 7303f81aad0..5a7419d6dce 100644 --- a/spec/frontend/ide/components/new_dropdown/upload_spec.js +++ b/spec/frontend/ide/components/new_dropdown/upload_spec.js @@ -69,25 +69,21 @@ describe('new dropdown upload', () => { jest.spyOn(FileReader.prototype, 'readAsText'); }); - it('calls readAsText and creates file in plain text (without encoding) if the file content is plain text', (done) => { + it('calls readAsText and creates file in plain text (without encoding) if the file content is plain text', async () => { const waitForCreate = new Promise((resolve) => vm.$on('create', resolve)); vm.createFile(textTarget, textFile); expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(textFile); - waitForCreate - .then(() => { - expect(vm.$emit).toHaveBeenCalledWith('create', { - name: textFile.name, - type: 'blob', - content: 'plain text', - rawPath: '', - mimeType: 'test/mime-text', - }); - }) - .then(done) - .catch(done.fail); + await waitForCreate; + expect(vm.$emit).toHaveBeenCalledWith('create', { + name: textFile.name, + type: 'blob', + content: 'plain text', + rawPath: '', + mimeType: 'test/mime-text', + }); }); it('creates a blob URL for the content if binary', () => { diff --git a/spec/frontend/ide/stores/actions/merge_request_spec.js b/spec/frontend/ide/stores/actions/merge_request_spec.js index e62811a4517..5592e2664c4 100644 --- a/spec/frontend/ide/stores/actions/merge_request_spec.js +++ b/spec/frontend/ide/stores/actions/merge_request_spec.js @@ -63,56 +63,47 @@ describe('IDE store merge request actions', () => { mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests/).reply(200, mockData); }); - it('calls getProjectMergeRequests service method', (done) => { - store - .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'bar' }) - .then(() => { - expect(service.getProjectMergeRequests).toHaveBeenCalledWith(TEST_PROJECT, { - source_branch: 'bar', - source_project_id: TEST_PROJECT_ID, - state: 'opened', - order_by: 'created_at', - per_page: 1, - }); - - done(); - }) - .catch(done.fail); + it('calls getProjectMergeRequests service method', async () => { + await store.dispatch('getMergeRequestsForBranch', { + projectId: TEST_PROJECT, + branchId: 'bar', + }); + expect(service.getProjectMergeRequests).toHaveBeenCalledWith(TEST_PROJECT, { + source_branch: 'bar', + source_project_id: TEST_PROJECT_ID, + state: 'opened', + order_by: 'created_at', + per_page: 1, + }); }); - it('sets the "Merge Request" Object', (done) => { - store - .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'bar' }) - .then(() => { - expect(store.state.projects.abcproject.mergeRequests).toEqual({ - 2: expect.objectContaining(mrData), - }); - done(); - }) - .catch(done.fail); + it('sets the "Merge Request" Object', async () => { + await store.dispatch('getMergeRequestsForBranch', { + projectId: TEST_PROJECT, + branchId: 'bar', + }); + expect(store.state.projects.abcproject.mergeRequests).toEqual({ + 2: expect.objectContaining(mrData), + }); }); - it('sets "Current Merge Request" object to the most recent MR', (done) => { - store - .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'bar' }) - .then(() => { - expect(store.state.currentMergeRequestId).toEqual('2'); - done(); - }) - .catch(done.fail); + it('sets "Current Merge Request" object to the most recent MR', async () => { + await store.dispatch('getMergeRequestsForBranch', { + projectId: TEST_PROJECT, + branchId: 'bar', + }); + expect(store.state.currentMergeRequestId).toEqual('2'); }); - it('does nothing if user cannot read MRs', (done) => { + it('does nothing if user cannot read MRs', async () => { store.state.projects[TEST_PROJECT].userPermissions[PERMISSION_READ_MR] = false; - store - .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'bar' }) - .then(() => { - expect(service.getProjectMergeRequests).not.toHaveBeenCalled(); - expect(store.state.currentMergeRequestId).toBe(''); - }) - .then(done) - .catch(done.fail); + await store.dispatch('getMergeRequestsForBranch', { + projectId: TEST_PROJECT, + branchId: 'bar', + }); + expect(service.getProjectMergeRequests).not.toHaveBeenCalled(); + expect(store.state.currentMergeRequestId).toBe(''); }); }); @@ -122,15 +113,13 @@ describe('IDE store merge request actions', () => { mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests/).reply(200, []); }); - it('does not fail if there are no merge requests for current branch', (done) => { - store - .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'foo' }) - .then(() => { - expect(store.state.projects[TEST_PROJECT].mergeRequests).toEqual({}); - expect(store.state.currentMergeRequestId).toEqual(''); - done(); - }) - .catch(done.fail); + it('does not fail if there are no merge requests for current branch', async () => { + await store.dispatch('getMergeRequestsForBranch', { + projectId: TEST_PROJECT, + branchId: 'foo', + }); + expect(store.state.projects[TEST_PROJECT].mergeRequests).toEqual({}); + expect(store.state.currentMergeRequestId).toEqual(''); }); }); }); @@ -140,17 +129,18 @@ describe('IDE store merge request actions', () => { mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests/).networkError(); }); - it('flashes message, if error', (done) => { - store - .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'bar' }) + it('flashes message, if error', () => { + return store + .dispatch('getMergeRequestsForBranch', { + projectId: TEST_PROJECT, + branchId: 'bar', + }) .catch(() => { expect(createFlash).toHaveBeenCalled(); expect(createFlash.mock.calls[0][0].message).toBe( 'Error fetching merge requests for bar', ); - }) - .then(done) - .catch(done.fail); + }); }); }); }); @@ -165,29 +155,15 @@ describe('IDE store merge request actions', () => { .reply(200, { title: 'mergerequest' }); }); - it('calls getProjectMergeRequestData service method', (done) => { - store - .dispatch('getMergeRequestData', { projectId: TEST_PROJECT, mergeRequestId: 1 }) - .then(() => { - expect(service.getProjectMergeRequestData).toHaveBeenCalledWith(TEST_PROJECT, 1); - - done(); - }) - .catch(done.fail); + it('calls getProjectMergeRequestData service method', async () => { + await store.dispatch('getMergeRequestData', { projectId: TEST_PROJECT, mergeRequestId: 1 }); + expect(service.getProjectMergeRequestData).toHaveBeenCalledWith(TEST_PROJECT, 1); }); - it('sets the Merge Request Object', (done) => { - store - .dispatch('getMergeRequestData', { projectId: TEST_PROJECT, mergeRequestId: 1 }) - .then(() => { - expect(store.state.currentMergeRequestId).toBe(1); - expect(store.state.projects[TEST_PROJECT].mergeRequests['1'].title).toBe( - 'mergerequest', - ); - - done(); - }) - .catch(done.fail); + it('sets the Merge Request Object', async () => { + await store.dispatch('getMergeRequestData', { projectId: TEST_PROJECT, mergeRequestId: 1 }); + expect(store.state.currentMergeRequestId).toBe(1); + expect(store.state.projects[TEST_PROJECT].mergeRequests['1'].title).toBe('mergerequest'); }); }); @@ -196,32 +172,28 @@ describe('IDE store merge request actions', () => { mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests\/1/).networkError(); }); - it('dispatches error action', (done) => { + it('dispatches error action', () => { const dispatch = jest.fn(); - getMergeRequestData( + return getMergeRequestData( { commit() {}, dispatch, state: store.state, }, { projectId: TEST_PROJECT, mergeRequestId: 1 }, - ) - .then(done.fail) - .catch(() => { - expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { - text: 'An error occurred while loading the merge request.', - action: expect.any(Function), - actionText: 'Please try again', - actionPayload: { - projectId: TEST_PROJECT, - mergeRequestId: 1, - force: false, - }, - }); - - done(); + ).catch(() => { + expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { + text: 'An error occurred while loading the merge request.', + action: expect.any(Function), + actionText: 'Please try again', + actionPayload: { + projectId: TEST_PROJECT, + mergeRequestId: 1, + force: false, + }, }); + }); }); }); }); @@ -240,27 +212,22 @@ describe('IDE store merge request actions', () => { .reply(200, { title: 'mergerequest' }); }); - it('calls getProjectMergeRequestChanges service method', (done) => { - store - .dispatch('getMergeRequestChanges', { projectId: TEST_PROJECT, mergeRequestId: 1 }) - .then(() => { - expect(service.getProjectMergeRequestChanges).toHaveBeenCalledWith(TEST_PROJECT, 1); - - done(); - }) - .catch(done.fail); + it('calls getProjectMergeRequestChanges service method', async () => { + await store.dispatch('getMergeRequestChanges', { + projectId: TEST_PROJECT, + mergeRequestId: 1, + }); + expect(service.getProjectMergeRequestChanges).toHaveBeenCalledWith(TEST_PROJECT, 1); }); - it('sets the Merge Request Changes Object', (done) => { - store - .dispatch('getMergeRequestChanges', { projectId: TEST_PROJECT, mergeRequestId: 1 }) - .then(() => { - expect(store.state.projects[TEST_PROJECT].mergeRequests['1'].changes.title).toBe( - 'mergerequest', - ); - done(); - }) - .catch(done.fail); + it('sets the Merge Request Changes Object', async () => { + await store.dispatch('getMergeRequestChanges', { + projectId: TEST_PROJECT, + mergeRequestId: 1, + }); + expect(store.state.projects[TEST_PROJECT].mergeRequests['1'].changes.title).toBe( + 'mergerequest', + ); }); }); @@ -269,32 +236,30 @@ describe('IDE store merge request actions', () => { mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests\/1\/changes/).networkError(); }); - it('dispatches error action', (done) => { + it('dispatches error action', async () => { const dispatch = jest.fn(); - getMergeRequestChanges( - { - commit() {}, - dispatch, - state: store.state, + await expect( + getMergeRequestChanges( + { + commit() {}, + dispatch, + state: store.state, + }, + { projectId: TEST_PROJECT, mergeRequestId: 1 }, + ), + ).rejects.toEqual(new Error('Merge request changes not loaded abcproject')); + + expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { + text: 'An error occurred while loading the merge request changes.', + action: expect.any(Function), + actionText: 'Please try again', + actionPayload: { + projectId: TEST_PROJECT, + mergeRequestId: 1, + force: false, }, - { projectId: TEST_PROJECT, mergeRequestId: 1 }, - ) - .then(done.fail) - .catch(() => { - expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { - text: 'An error occurred while loading the merge request changes.', - action: expect.any(Function), - actionText: 'Please try again', - actionPayload: { - projectId: TEST_PROJECT, - mergeRequestId: 1, - force: false, - }, - }); - - done(); - }); + }); }); }); }); @@ -312,25 +277,20 @@ describe('IDE store merge request actions', () => { jest.spyOn(service, 'getProjectMergeRequestVersions'); }); - it('calls getProjectMergeRequestVersions service method', (done) => { - store - .dispatch('getMergeRequestVersions', { projectId: TEST_PROJECT, mergeRequestId: 1 }) - .then(() => { - expect(service.getProjectMergeRequestVersions).toHaveBeenCalledWith(TEST_PROJECT, 1); - - done(); - }) - .catch(done.fail); + it('calls getProjectMergeRequestVersions service method', async () => { + await store.dispatch('getMergeRequestVersions', { + projectId: TEST_PROJECT, + mergeRequestId: 1, + }); + expect(service.getProjectMergeRequestVersions).toHaveBeenCalledWith(TEST_PROJECT, 1); }); - it('sets the Merge Request Versions Object', (done) => { - store - .dispatch('getMergeRequestVersions', { projectId: TEST_PROJECT, mergeRequestId: 1 }) - .then(() => { - expect(store.state.projects[TEST_PROJECT].mergeRequests['1'].versions.length).toBe(1); - done(); - }) - .catch(done.fail); + it('sets the Merge Request Versions Object', async () => { + await store.dispatch('getMergeRequestVersions', { + projectId: TEST_PROJECT, + mergeRequestId: 1, + }); + expect(store.state.projects[TEST_PROJECT].mergeRequests['1'].versions.length).toBe(1); }); }); @@ -339,32 +299,28 @@ describe('IDE store merge request actions', () => { mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests\/1\/versions/).networkError(); }); - it('dispatches error action', (done) => { + it('dispatches error action', () => { const dispatch = jest.fn(); - getMergeRequestVersions( + return getMergeRequestVersions( { commit() {}, dispatch, state: store.state, }, { projectId: TEST_PROJECT, mergeRequestId: 1 }, - ) - .then(done.fail) - .catch(() => { - expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { - text: 'An error occurred while loading the merge request version data.', - action: expect.any(Function), - actionText: 'Please try again', - actionPayload: { - projectId: TEST_PROJECT, - mergeRequestId: 1, - force: false, - }, - }); - - done(); + ).catch(() => { + expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { + text: 'An error occurred while loading the merge request version data.', + action: expect.any(Function), + actionText: 'Please try again', + actionPayload: { + projectId: TEST_PROJECT, + mergeRequestId: 1, + force: false, + }, }); + }); }); }); }); @@ -503,37 +459,36 @@ describe('IDE store merge request actions', () => { ); }); - it('dispatches actions for merge request data', (done) => { - openMergeRequest({ state: store.state, dispatch: store.dispatch, getters: mockGetters }, mr) - .then(() => { - expect(store.dispatch.mock.calls).toEqual([ - ['getMergeRequestData', mr], - ['setCurrentBranchId', testMergeRequest.source_branch], - [ - 'getBranchData', - { - projectId: mr.projectId, - branchId: testMergeRequest.source_branch, - }, - ], - [ - 'getFiles', - { - projectId: mr.projectId, - branchId: testMergeRequest.source_branch, - ref: 'abcd2322', - }, - ], - ['getMergeRequestVersions', mr], - ['getMergeRequestChanges', mr], - ['openMergeRequestChanges', testMergeRequestChanges.changes], - ]); - }) - .then(done) - .catch(done.fail); + it('dispatches actions for merge request data', async () => { + await openMergeRequest( + { state: store.state, dispatch: store.dispatch, getters: mockGetters }, + mr, + ); + expect(store.dispatch.mock.calls).toEqual([ + ['getMergeRequestData', mr], + ['setCurrentBranchId', testMergeRequest.source_branch], + [ + 'getBranchData', + { + projectId: mr.projectId, + branchId: testMergeRequest.source_branch, + }, + ], + [ + 'getFiles', + { + projectId: mr.projectId, + branchId: testMergeRequest.source_branch, + ref: 'abcd2322', + }, + ], + ['getMergeRequestVersions', mr], + ['getMergeRequestChanges', mr], + ['openMergeRequestChanges', testMergeRequestChanges.changes], + ]); }); - it('updates activity bar view and gets file data, if changes are found', (done) => { + it('updates activity bar view and gets file data, if changes are found', async () => { store.state.entries.foo = { type: 'blob', path: 'foo', @@ -548,28 +503,24 @@ describe('IDE store merge request actions', () => { { new_path: 'bar', path: 'bar' }, ]; - openMergeRequest({ state: store.state, dispatch: store.dispatch, getters: mockGetters }, mr) - .then(() => { - expect(store.dispatch).toHaveBeenCalledWith( - 'openMergeRequestChanges', - testMergeRequestChanges.changes, - ); - }) - .then(done) - .catch(done.fail); + await openMergeRequest( + { state: store.state, dispatch: store.dispatch, getters: mockGetters }, + mr, + ); + expect(store.dispatch).toHaveBeenCalledWith( + 'openMergeRequestChanges', + testMergeRequestChanges.changes, + ); }); - it('flashes message, if error', (done) => { + it('flashes message, if error', () => { store.dispatch.mockRejectedValue(); - openMergeRequest(store, mr) - .catch(() => { - expect(createFlash).toHaveBeenCalledWith({ - message: expect.any(String), - }); - }) - .then(done) - .catch(done.fail); + return openMergeRequest(store, mr).catch(() => { + expect(createFlash).toHaveBeenCalledWith({ + message: expect.any(String), + }); + }); }); }); }); diff --git a/spec/frontend/ide/stores/actions/project_spec.js b/spec/frontend/ide/stores/actions/project_spec.js index e07dcf22860..cc7d39b4d43 100644 --- a/spec/frontend/ide/stores/actions/project_spec.js +++ b/spec/frontend/ide/stores/actions/project_spec.js @@ -146,22 +146,16 @@ describe('IDE store project actions', () => { }); }); - it('calls the service', (done) => { - store - .dispatch('refreshLastCommitData', { - projectId: store.state.currentProjectId, - branchId: store.state.currentBranchId, - }) - .then(() => { - expect(service.getBranchData).toHaveBeenCalledWith('abc/def', 'main'); - - done(); - }) - .catch(done.fail); + it('calls the service', async () => { + await store.dispatch('refreshLastCommitData', { + projectId: store.state.currentProjectId, + branchId: store.state.currentBranchId, + }); + expect(service.getBranchData).toHaveBeenCalledWith('abc/def', 'main'); }); - it('commits getBranchData', (done) => { - testAction( + it('commits getBranchData', () => { + return testAction( refreshLastCommitData, { projectId: store.state.currentProjectId, @@ -181,14 +175,13 @@ describe('IDE store project actions', () => { ], // action [], - done, ); }); }); describe('showBranchNotFoundError', () => { - it('dispatches setErrorMessage', (done) => { - testAction( + it('dispatches setErrorMessage', () => { + return testAction( showBranchNotFoundError, 'main', null, @@ -204,7 +197,6 @@ describe('IDE store project actions', () => { }, }, ], - done, ); }); }); @@ -216,8 +208,8 @@ describe('IDE store project actions', () => { jest.spyOn(api, 'createBranch').mockResolvedValue(); }); - it('calls API', (done) => { - createNewBranchFromDefault( + it('calls API', async () => { + await createNewBranchFromDefault( { state: { currentProjectId: 'project-path', @@ -230,21 +222,17 @@ describe('IDE store project actions', () => { dispatch() {}, }, 'new-branch-name', - ) - .then(() => { - expect(api.createBranch).toHaveBeenCalledWith('project-path', { - ref: 'main', - branch: 'new-branch-name', - }); - }) - .then(done) - .catch(done.fail); + ); + expect(api.createBranch).toHaveBeenCalledWith('project-path', { + ref: 'main', + branch: 'new-branch-name', + }); }); - it('clears error message', (done) => { + it('clears error message', async () => { const dispatchSpy = jest.fn().mockName('dispatch'); - createNewBranchFromDefault( + await createNewBranchFromDefault( { state: { currentProjectId: 'project-path', @@ -257,16 +245,12 @@ describe('IDE store project actions', () => { dispatch: dispatchSpy, }, 'new-branch-name', - ) - .then(() => { - expect(dispatchSpy).toHaveBeenCalledWith('setErrorMessage', null); - }) - .then(done) - .catch(done.fail); + ); + expect(dispatchSpy).toHaveBeenCalledWith('setErrorMessage', null); }); - it('reloads window', (done) => { - createNewBranchFromDefault( + it('reloads window', async () => { + await createNewBranchFromDefault( { state: { currentProjectId: 'project-path', @@ -279,18 +263,14 @@ describe('IDE store project actions', () => { dispatch() {}, }, 'new-branch-name', - ) - .then(() => { - expect(window.location.reload).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + ); + expect(window.location.reload).toHaveBeenCalled(); }); }); describe('loadEmptyBranch', () => { - it('creates a blank tree and sets loading state to false', (done) => { - testAction( + it('creates a blank tree and sets loading state to false', () => { + return testAction( loadEmptyBranch, { projectId: TEST_PROJECT_ID, branchId: 'main' }, store.state, @@ -302,20 +282,18 @@ describe('IDE store project actions', () => { }, ], expect.any(Object), - done, ); }); - it('does nothing, if tree already exists', (done) => { + it('does nothing, if tree already exists', () => { const trees = { [`${TEST_PROJECT_ID}/main`]: [] }; - testAction( + return testAction( loadEmptyBranch, { projectId: TEST_PROJECT_ID, branchId: 'main' }, { trees }, [], [], - done, ); }); }); @@ -372,56 +350,48 @@ describe('IDE store project actions', () => { const branchId = '123-lorem'; const ref = 'abcd2322'; - it('when empty repo, loads empty branch', (done) => { + it('when empty repo, loads empty branch', () => { const mockGetters = { emptyRepo: true }; - testAction( + return testAction( loadBranch, { projectId, branchId }, { ...store.state, ...mockGetters }, [], [{ type: 'loadEmptyBranch', payload: { projectId, branchId } }], - done, ); }); - it('when branch already exists, does nothing', (done) => { + it('when branch already exists, does nothing', () => { store.state.projects[projectId].branches[branchId] = {}; - testAction(loadBranch, { projectId, branchId }, store.state, [], [], done); + return testAction(loadBranch, { projectId, branchId }, store.state, [], []); }); - it('fetches branch data', (done) => { + it('fetches branch data', async () => { const mockGetters = { findBranch: () => ({ commit: { id: ref } }) }; jest.spyOn(store, 'dispatch').mockResolvedValue(); - loadBranch( + await loadBranch( { getters: mockGetters, state: store.state, dispatch: store.dispatch }, { projectId, branchId }, - ) - .then(() => { - expect(store.dispatch.mock.calls).toEqual([ - ['getBranchData', { projectId, branchId }], - ['getMergeRequestsForBranch', { projectId, branchId }], - ['getFiles', { projectId, branchId, ref }], - ]); - }) - .then(done) - .catch(done.fail); + ); + expect(store.dispatch.mock.calls).toEqual([ + ['getBranchData', { projectId, branchId }], + ['getMergeRequestsForBranch', { projectId, branchId }], + ['getFiles', { projectId, branchId, ref }], + ]); }); - it('shows an error if branch can not be fetched', (done) => { + it('shows an error if branch can not be fetched', async () => { jest.spyOn(store, 'dispatch').mockReturnValue(Promise.reject()); - loadBranch(store, { projectId, branchId }) - .then(done.fail) - .catch(() => { - expect(store.dispatch.mock.calls).toEqual([ - ['getBranchData', { projectId, branchId }], - ['showBranchNotFoundError', branchId], - ]); - done(); - }); + await expect(loadBranch(store, { projectId, branchId })).rejects.toBeUndefined(); + + expect(store.dispatch.mock.calls).toEqual([ + ['getBranchData', { projectId, branchId }], + ['showBranchNotFoundError', branchId], + ]); }); }); @@ -449,17 +419,13 @@ describe('IDE store project actions', () => { jest.spyOn(store, 'dispatch').mockResolvedValue(); }); - it('dispatches branch actions', (done) => { - openBranch(store, branch) - .then(() => { - expect(store.dispatch.mock.calls).toEqual([ - ['setCurrentBranchId', branchId], - ['loadBranch', { projectId, branchId }], - ['loadFile', { basePath: undefined }], - ]); - }) - .then(done) - .catch(done.fail); + it('dispatches branch actions', async () => { + await openBranch(store, branch); + expect(store.dispatch.mock.calls).toEqual([ + ['setCurrentBranchId', branchId], + ['loadBranch', { projectId, branchId }], + ['loadFile', { basePath: undefined }], + ]); }); }); @@ -468,22 +434,18 @@ describe('IDE store project actions', () => { jest.spyOn(store, 'dispatch').mockReturnValue(Promise.reject()); }); - it('dispatches correct branch actions', (done) => { - openBranch(store, branch) - .then((val) => { - expect(store.dispatch.mock.calls).toEqual([ - ['setCurrentBranchId', branchId], - ['loadBranch', { projectId, branchId }], - ]); - - expect(val).toEqual( - new Error( - `An error occurred while getting files for - <strong>${projectId}/${branchId}</strong>`, - ), - ); - }) - .then(done) - .catch(done.fail); + it('dispatches correct branch actions', async () => { + const val = await openBranch(store, branch); + expect(store.dispatch.mock.calls).toEqual([ + ['setCurrentBranchId', branchId], + ['loadBranch', { projectId, branchId }], + ]); + + expect(val).toEqual( + new Error( + `An error occurred while getting files for - <strong>${projectId}/${branchId}</strong>`, + ), + ); }); }); }); diff --git a/spec/frontend/ide/stores/actions/tree_spec.js b/spec/frontend/ide/stores/actions/tree_spec.js index 8d7328725e9..fc44cbb21ae 100644 --- a/spec/frontend/ide/stores/actions/tree_spec.js +++ b/spec/frontend/ide/stores/actions/tree_spec.js @@ -62,27 +62,21 @@ describe('Multi-file store tree actions', () => { }); }); - it('adds data into tree', (done) => { - store - .dispatch('getFiles', basicCallParameters) - .then(() => { - projectTree = store.state.trees['abcproject/main']; - - expect(projectTree.tree.length).toBe(2); - expect(projectTree.tree[0].type).toBe('tree'); - expect(projectTree.tree[0].tree[1].name).toBe('fileinfolder.js'); - expect(projectTree.tree[1].type).toBe('blob'); - expect(projectTree.tree[0].tree[0].tree[0].type).toBe('blob'); - expect(projectTree.tree[0].tree[0].tree[0].name).toBe('fileinsubfolder.js'); - - done(); - }) - .catch(done.fail); + it('adds data into tree', async () => { + await store.dispatch('getFiles', basicCallParameters); + projectTree = store.state.trees['abcproject/main']; + + expect(projectTree.tree.length).toBe(2); + expect(projectTree.tree[0].type).toBe('tree'); + expect(projectTree.tree[0].tree[1].name).toBe('fileinfolder.js'); + expect(projectTree.tree[1].type).toBe('blob'); + expect(projectTree.tree[0].tree[0].tree[0].type).toBe('blob'); + expect(projectTree.tree[0].tree[0].tree[0].name).toBe('fileinsubfolder.js'); }); }); describe('error', () => { - it('dispatches error action', (done) => { + it('dispatches error action', async () => { const dispatch = jest.fn(); store.state.projects = { @@ -103,28 +97,26 @@ describe('Multi-file store tree actions', () => { mock.onGet(/(.*)/).replyOnce(500); - getFiles( - { - commit() {}, - dispatch, - state: store.state, - getters, - }, - { - projectId: 'abc/def', - branchId: 'main-testing', - }, - ) - .then(done.fail) - .catch(() => { - expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { - text: 'An error occurred while loading all the files.', - action: expect.any(Function), - actionText: 'Please try again', - actionPayload: { projectId: 'abc/def', branchId: 'main-testing' }, - }); - done(); - }); + await expect( + getFiles( + { + commit() {}, + dispatch, + state: store.state, + getters, + }, + { + projectId: 'abc/def', + branchId: 'main-testing', + }, + ), + ).rejects.toEqual(new Error('Request failed with status code 500')); + expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { + text: 'An error occurred while loading all the files.', + action: expect.any(Function), + actionText: 'Please try again', + actionPayload: { projectId: 'abc/def', branchId: 'main-testing' }, + }); }); }); }); @@ -137,15 +129,9 @@ describe('Multi-file store tree actions', () => { store.state.entries[tree.path] = tree; }); - it('toggles the tree open', (done) => { - store - .dispatch('toggleTreeOpen', tree.path) - .then(() => { - expect(tree.opened).toBeTruthy(); - - done(); - }) - .catch(done.fail); + it('toggles the tree open', async () => { + await store.dispatch('toggleTreeOpen', tree.path); + expect(tree.opened).toBeTruthy(); }); }); @@ -163,24 +149,23 @@ describe('Multi-file store tree actions', () => { Object.assign(store.state.entries, createEntriesFromPaths(paths)); }); - it('opens the parents', (done) => { - testAction( + it('opens the parents', () => { + return testAction( showTreeEntry, 'grandparent/parent/child.txt', store.state, [{ type: types.SET_TREE_OPEN, payload: 'grandparent/parent' }], [{ type: 'showTreeEntry', payload: 'grandparent/parent' }], - done, ); }); }); describe('setDirectoryData', () => { - it('sets tree correctly if there are no opened files yet', (done) => { + it('sets tree correctly if there are no opened files yet', () => { const treeFile = file({ name: 'README.md' }); store.state.trees['abcproject/main'] = {}; - testAction( + return testAction( setDirectoryData, { projectId: 'abcproject', branchId: 'main', treeList: [treeFile] }, store.state, @@ -201,7 +186,6 @@ describe('Multi-file store tree actions', () => { }, ], [], - done, ); }); }); diff --git a/spec/frontend/ide/stores/actions_spec.js b/spec/frontend/ide/stores/actions_spec.js index be43c618095..3889c4f11c3 100644 --- a/spec/frontend/ide/stores/actions_spec.js +++ b/spec/frontend/ide/stores/actions_spec.js @@ -43,15 +43,9 @@ describe('Multi-file store actions', () => { }); describe('redirectToUrl', () => { - it('calls visitUrl', (done) => { - store - .dispatch('redirectToUrl', 'test') - .then(() => { - expect(visitUrl).toHaveBeenCalledWith('test'); - - done(); - }) - .catch(done.fail); + it('calls visitUrl', async () => { + await store.dispatch('redirectToUrl', 'test'); + expect(visitUrl).toHaveBeenCalledWith('test'); }); }); @@ -89,15 +83,10 @@ describe('Multi-file store actions', () => { expect(store.dispatch.mock.calls).toEqual(expect.arrayContaining(expectedCalls)); }); - it('removes all files from changedFiles state', (done) => { - store - .dispatch('discardAllChanges') - .then(() => { - expect(store.state.changedFiles.length).toBe(0); - expect(store.state.openFiles.length).toBe(2); - }) - .then(done) - .catch(done.fail); + it('removes all files from changedFiles state', async () => { + await store.dispatch('discardAllChanges'); + expect(store.state.changedFiles.length).toBe(0); + expect(store.state.openFiles.length).toBe(2); }); }); @@ -121,24 +110,18 @@ describe('Multi-file store actions', () => { }); describe('tree', () => { - it('creates temp tree', (done) => { - store - .dispatch('createTempEntry', { - name: 'test', - type: 'tree', - }) - .then(() => { - const entry = store.state.entries.test; - - expect(entry).not.toBeNull(); - expect(entry.type).toBe('tree'); + it('creates temp tree', async () => { + await store.dispatch('createTempEntry', { + name: 'test', + type: 'tree', + }); + const entry = store.state.entries.test; - done(); - }) - .catch(done.fail); + expect(entry).not.toBeNull(); + expect(entry.type).toBe('tree'); }); - it('creates new folder inside another tree', (done) => { + it('creates new folder inside another tree', async () => { const tree = { type: 'tree', name: 'testing', @@ -148,22 +131,16 @@ describe('Multi-file store actions', () => { store.state.entries[tree.path] = tree; - store - .dispatch('createTempEntry', { - name: 'testing/test', - type: 'tree', - }) - .then(() => { - expect(tree.tree[0].tempFile).toBeTruthy(); - expect(tree.tree[0].name).toBe('test'); - expect(tree.tree[0].type).toBe('tree'); - - done(); - }) - .catch(done.fail); + await store.dispatch('createTempEntry', { + name: 'testing/test', + type: 'tree', + }); + expect(tree.tree[0].tempFile).toBeTruthy(); + expect(tree.tree[0].name).toBe('test'); + expect(tree.tree[0].type).toBe('tree'); }); - it('does not create new tree if already exists', (done) => { + it('does not create new tree if already exists', async () => { const tree = { type: 'tree', path: 'testing', @@ -173,76 +150,52 @@ describe('Multi-file store actions', () => { store.state.entries[tree.path] = tree; - store - .dispatch('createTempEntry', { - name: 'testing', - type: 'tree', - }) - .then(() => { - expect(store.state.entries[tree.path].tempFile).toEqual(false); - expect(document.querySelector('.flash-alert')).not.toBeNull(); - - done(); - }) - .catch(done.fail); + await store.dispatch('createTempEntry', { + name: 'testing', + type: 'tree', + }); + expect(store.state.entries[tree.path].tempFile).toEqual(false); + expect(document.querySelector('.flash-alert')).not.toBeNull(); }); }); describe('blob', () => { - it('creates temp file', (done) => { + it('creates temp file', async () => { const name = 'test'; - store - .dispatch('createTempEntry', { - name, - type: 'blob', - mimeType: 'test/mime', - }) - .then(() => { - const f = store.state.entries[name]; - - expect(f.tempFile).toBeTruthy(); - expect(f.mimeType).toBe('test/mime'); - expect(store.state.trees['abcproject/mybranch'].tree.length).toBe(1); - - done(); - }) - .catch(done.fail); + await store.dispatch('createTempEntry', { + name, + type: 'blob', + mimeType: 'test/mime', + }); + const f = store.state.entries[name]; + + expect(f.tempFile).toBeTruthy(); + expect(f.mimeType).toBe('test/mime'); + expect(store.state.trees['abcproject/mybranch'].tree.length).toBe(1); }); - it('adds tmp file to open files', (done) => { + it('adds tmp file to open files', async () => { const name = 'test'; - store - .dispatch('createTempEntry', { - name, - type: 'blob', - }) - .then(() => { - const f = store.state.entries[name]; - - expect(store.state.openFiles.length).toBe(1); - expect(store.state.openFiles[0].name).toBe(f.name); + await store.dispatch('createTempEntry', { + name, + type: 'blob', + }); + const f = store.state.entries[name]; - done(); - }) - .catch(done.fail); + expect(store.state.openFiles.length).toBe(1); + expect(store.state.openFiles[0].name).toBe(f.name); }); - it('adds tmp file to staged files', (done) => { + it('adds tmp file to staged files', async () => { const name = 'test'; - store - .dispatch('createTempEntry', { - name, - type: 'blob', - }) - .then(() => { - expect(store.state.stagedFiles).toEqual([expect.objectContaining({ name })]); - - done(); - }) - .catch(done.fail); + await store.dispatch('createTempEntry', { + name, + type: 'blob', + }); + expect(store.state.stagedFiles).toEqual([expect.objectContaining({ name })]); }); it('sets tmp file as active', () => { @@ -251,24 +204,18 @@ describe('Multi-file store actions', () => { expect(store.dispatch).toHaveBeenCalledWith('setFileActive', 'test'); }); - it('creates flash message if file already exists', (done) => { + it('creates flash message if file already exists', async () => { const f = file('test', '1', 'blob'); store.state.trees['abcproject/mybranch'].tree = [f]; store.state.entries[f.path] = f; - store - .dispatch('createTempEntry', { - name: 'test', - type: 'blob', - }) - .then(() => { - expect(document.querySelector('.flash-alert')?.textContent.trim()).toEqual( - `The name "${f.name}" is already taken in this directory.`, - ); - - done(); - }) - .catch(done.fail); + await store.dispatch('createTempEntry', { + name: 'test', + type: 'blob', + }); + expect(document.querySelector('.flash-alert')?.textContent.trim()).toEqual( + `The name "${f.name}" is already taken in this directory.`, + ); }); }); }); @@ -372,45 +319,38 @@ describe('Multi-file store actions', () => { }); describe('updateViewer', () => { - it('updates viewer state', (done) => { - store - .dispatch('updateViewer', 'diff') - .then(() => { - expect(store.state.viewer).toBe('diff'); - }) - .then(done) - .catch(done.fail); + it('updates viewer state', async () => { + await store.dispatch('updateViewer', 'diff'); + expect(store.state.viewer).toBe('diff'); }); }); describe('updateActivityBarView', () => { - it('commits UPDATE_ACTIVITY_BAR_VIEW', (done) => { - testAction( + it('commits UPDATE_ACTIVITY_BAR_VIEW', () => { + return testAction( updateActivityBarView, 'test', {}, [{ type: 'UPDATE_ACTIVITY_BAR_VIEW', payload: 'test' }], [], - done, ); }); }); describe('setEmptyStateSvgs', () => { - it('commits setEmptyStateSvgs', (done) => { - testAction( + it('commits setEmptyStateSvgs', () => { + return testAction( setEmptyStateSvgs, 'svg', {}, [{ type: 'SET_EMPTY_STATE_SVGS', payload: 'svg' }], [], - done, ); }); }); describe('updateTempFlagForEntry', () => { - it('commits UPDATE_TEMP_FLAG', (done) => { + it('commits UPDATE_TEMP_FLAG', () => { const f = { ...file(), path: 'test', @@ -418,17 +358,16 @@ describe('Multi-file store actions', () => { }; store.state.entries[f.path] = f; - testAction( + return testAction( updateTempFlagForEntry, { file: f, tempFile: false }, store.state, [{ type: 'UPDATE_TEMP_FLAG', payload: { path: f.path, tempFile: false } }], [], - done, ); }); - it('commits UPDATE_TEMP_FLAG and dispatches for parent', (done) => { + it('commits UPDATE_TEMP_FLAG and dispatches for parent', () => { const parent = { ...file(), path: 'testing', @@ -441,17 +380,16 @@ describe('Multi-file store actions', () => { store.state.entries[parent.path] = parent; store.state.entries[f.path] = f; - testAction( + return testAction( updateTempFlagForEntry, { file: f, tempFile: false }, store.state, [{ type: 'UPDATE_TEMP_FLAG', payload: { path: f.path, tempFile: false } }], [{ type: 'updateTempFlagForEntry', payload: { file: parent, tempFile: false } }], - done, ); }); - it('does not dispatch for parent, if parent does not exist', (done) => { + it('does not dispatch for parent, if parent does not exist', () => { const f = { ...file(), path: 'test', @@ -459,71 +397,66 @@ describe('Multi-file store actions', () => { }; store.state.entries[f.path] = f; - testAction( + return testAction( updateTempFlagForEntry, { file: f, tempFile: false }, store.state, [{ type: 'UPDATE_TEMP_FLAG', payload: { path: f.path, tempFile: false } }], [], - done, ); }); }); describe('setCurrentBranchId', () => { - it('commits setCurrentBranchId', (done) => { - testAction( + it('commits setCurrentBranchId', () => { + return testAction( setCurrentBranchId, 'branchId', {}, [{ type: 'SET_CURRENT_BRANCH', payload: 'branchId' }], [], - done, ); }); }); describe('toggleFileFinder', () => { - it('commits TOGGLE_FILE_FINDER', (done) => { - testAction( + it('commits TOGGLE_FILE_FINDER', () => { + return testAction( toggleFileFinder, true, null, [{ type: 'TOGGLE_FILE_FINDER', payload: true }], [], - done, ); }); }); describe('setErrorMessage', () => { - it('commis error messsage', (done) => { - testAction( + it('commis error messsage', () => { + return testAction( setErrorMessage, 'error', null, [{ type: types.SET_ERROR_MESSAGE, payload: 'error' }], [], - done, ); }); }); describe('deleteEntry', () => { - it('commits entry deletion', (done) => { + it('commits entry deletion', () => { store.state.entries.path = 'testing'; - testAction( + return testAction( deleteEntry, 'path', store.state, [{ type: types.DELETE_ENTRY, payload: 'path' }], [{ type: 'stageChange', payload: 'path' }, createTriggerChangeAction()], - done, ); }); - it('does not delete a folder after it is emptied', (done) => { + it('does not delete a folder after it is emptied', () => { const testFolder = { type: 'tree', tree: [], @@ -540,7 +473,7 @@ describe('Multi-file store actions', () => { 'testFolder/entry-to-delete': testEntry, }; - testAction( + return testAction( deleteEntry, 'testFolder/entry-to-delete', store.state, @@ -549,7 +482,6 @@ describe('Multi-file store actions', () => { { type: 'stageChange', payload: 'testFolder/entry-to-delete' }, createTriggerChangeAction(), ], - done, ); }); @@ -569,8 +501,8 @@ describe('Multi-file store actions', () => { }); describe('and previous does not exist', () => { - it('reverts the rename before deleting', (done) => { - testAction( + it('reverts the rename before deleting', () => { + return testAction( deleteEntry, testEntry.path, store.state, @@ -589,7 +521,6 @@ describe('Multi-file store actions', () => { payload: testEntry.prevPath, }, ], - done, ); }); }); @@ -604,21 +535,20 @@ describe('Multi-file store actions', () => { store.state.entries[oldEntry.path] = oldEntry; }); - it('does not revert rename before deleting', (done) => { - testAction( + it('does not revert rename before deleting', () => { + return testAction( deleteEntry, testEntry.path, store.state, [{ type: types.DELETE_ENTRY, payload: testEntry.path }], [{ type: 'stageChange', payload: testEntry.path }, createTriggerChangeAction()], - done, ); }); - it('when previous is deleted, it reverts rename before deleting', (done) => { + it('when previous is deleted, it reverts rename before deleting', () => { store.state.entries[testEntry.prevPath].deleted = true; - testAction( + return testAction( deleteEntry, testEntry.path, store.state, @@ -637,7 +567,6 @@ describe('Multi-file store actions', () => { payload: testEntry.prevPath, }, ], - done, ); }); }); @@ -650,7 +579,7 @@ describe('Multi-file store actions', () => { jest.spyOn(eventHub, '$emit').mockImplementation(); }); - it('does not purge model cache for temporary entries that got renamed', (done) => { + it('does not purge model cache for temporary entries that got renamed', async () => { Object.assign(store.state.entries, { test: { ...file('test'), @@ -660,19 +589,14 @@ describe('Multi-file store actions', () => { }, }); - store - .dispatch('renameEntry', { - path: 'test', - name: 'new', - }) - .then(() => { - expect(eventHub.$emit.mock.calls).not.toContain('editor.update.model.dispose.foo-bar'); - }) - .then(done) - .catch(done.fail); + await store.dispatch('renameEntry', { + path: 'test', + name: 'new', + }); + expect(eventHub.$emit.mock.calls).not.toContain('editor.update.model.dispose.foo-bar'); }); - it('purges model cache for renamed entry', (done) => { + it('purges model cache for renamed entry', async () => { Object.assign(store.state.entries, { test: { ...file('test'), @@ -682,17 +606,12 @@ describe('Multi-file store actions', () => { }, }); - store - .dispatch('renameEntry', { - path: 'test', - name: 'new', - }) - .then(() => { - expect(eventHub.$emit).toHaveBeenCalled(); - expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.dispose.foo-key`); - }) - .then(done) - .catch(done.fail); + await store.dispatch('renameEntry', { + path: 'test', + name: 'new', + }); + expect(eventHub.$emit).toHaveBeenCalled(); + expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.dispose.foo-key`); }); }); @@ -731,8 +650,8 @@ describe('Multi-file store actions', () => { ]); }); - it('if not changed, completely unstages and discards entry if renamed to original', (done) => { - testAction( + it('if not changed, completely unstages and discards entry if renamed to original', () => { + return testAction( renameEntry, { path: 'renamed', name: 'orig' }, store.state, @@ -751,24 +670,22 @@ describe('Multi-file store actions', () => { }, ], [createTriggerRenameAction('renamed', 'orig')], - done, ); }); - it('if already in changed, does not add to change', (done) => { + it('if already in changed, does not add to change', () => { store.state.changedFiles.push(renamedEntry); - testAction( + return testAction( renameEntry, { path: 'orig', name: 'renamed' }, store.state, [expect.objectContaining({ type: types.RENAME_ENTRY })], [createTriggerRenameAction('orig', 'renamed')], - done, ); }); - it('routes to the renamed file if the original file has been opened', (done) => { + it('routes to the renamed file if the original file has been opened', async () => { store.state.currentProjectId = 'test/test'; store.state.currentBranchId = 'main'; @@ -776,17 +693,12 @@ describe('Multi-file store actions', () => { opened: true, }); - store - .dispatch('renameEntry', { - path: 'orig', - name: 'renamed', - }) - .then(() => { - expect(router.push.mock.calls).toHaveLength(1); - expect(router.push).toHaveBeenCalledWith(`/project/test/test/tree/main/-/renamed/`); - }) - .then(done) - .catch(done.fail); + await store.dispatch('renameEntry', { + path: 'orig', + name: 'renamed', + }); + expect(router.push.mock.calls).toHaveLength(1); + expect(router.push).toHaveBeenCalledWith(`/project/test/test/tree/main/-/renamed/`); }); }); @@ -809,25 +721,20 @@ describe('Multi-file store actions', () => { }); }); - it('updates entries in a folder correctly, when folder is renamed', (done) => { - store - .dispatch('renameEntry', { - path: 'folder', - name: 'new-folder', - }) - .then(() => { - const keys = Object.keys(store.state.entries); - - expect(keys.length).toBe(3); - expect(keys.indexOf('new-folder')).toBe(0); - expect(keys.indexOf('new-folder/file-1')).toBe(1); - expect(keys.indexOf('new-folder/file-2')).toBe(2); - }) - .then(done) - .catch(done.fail); + it('updates entries in a folder correctly, when folder is renamed', async () => { + await store.dispatch('renameEntry', { + path: 'folder', + name: 'new-folder', + }); + const keys = Object.keys(store.state.entries); + + expect(keys.length).toBe(3); + expect(keys.indexOf('new-folder')).toBe(0); + expect(keys.indexOf('new-folder/file-1')).toBe(1); + expect(keys.indexOf('new-folder/file-2')).toBe(2); }); - it('discards renaming of an entry if the root folder is renamed back to a previous name', (done) => { + it('discards renaming of an entry if the root folder is renamed back to a previous name', async () => { const rootFolder = file('old-folder', 'old-folder', 'tree'); const testEntry = file('test', 'test', 'blob', rootFolder); @@ -841,53 +748,45 @@ describe('Multi-file store actions', () => { }, }); - store - .dispatch('renameEntry', { - path: 'old-folder', - name: 'new-folder', - }) - .then(() => { - const { entries } = store.state; - - expect(Object.keys(entries).length).toBe(2); - expect(entries['old-folder']).toBeUndefined(); - expect(entries['old-folder/test']).toBeUndefined(); - - expect(entries['new-folder']).toBeDefined(); - expect(entries['new-folder/test']).toEqual( - expect.objectContaining({ - path: 'new-folder/test', - name: 'test', - prevPath: 'old-folder/test', - prevName: 'test', - }), - ); - }) - .then(() => - store.dispatch('renameEntry', { - path: 'new-folder', - name: 'old-folder', - }), - ) - .then(() => { - const { entries } = store.state; - - expect(Object.keys(entries).length).toBe(2); - expect(entries['new-folder']).toBeUndefined(); - expect(entries['new-folder/test']).toBeUndefined(); - - expect(entries['old-folder']).toBeDefined(); - expect(entries['old-folder/test']).toEqual( - expect.objectContaining({ - path: 'old-folder/test', - name: 'test', - prevPath: undefined, - prevName: undefined, - }), - ); - }) - .then(done) - .catch(done.fail); + await store.dispatch('renameEntry', { + path: 'old-folder', + name: 'new-folder', + }); + const { entries } = store.state; + + expect(Object.keys(entries).length).toBe(2); + expect(entries['old-folder']).toBeUndefined(); + expect(entries['old-folder/test']).toBeUndefined(); + + expect(entries['new-folder']).toBeDefined(); + expect(entries['new-folder/test']).toEqual( + expect.objectContaining({ + path: 'new-folder/test', + name: 'test', + prevPath: 'old-folder/test', + prevName: 'test', + }), + ); + + await store.dispatch('renameEntry', { + path: 'new-folder', + name: 'old-folder', + }); + const { entries: newEntries } = store.state; + + expect(Object.keys(newEntries).length).toBe(2); + expect(newEntries['new-folder']).toBeUndefined(); + expect(newEntries['new-folder/test']).toBeUndefined(); + + expect(newEntries['old-folder']).toBeDefined(); + expect(newEntries['old-folder/test']).toEqual( + expect.objectContaining({ + path: 'old-folder/test', + name: 'test', + prevPath: undefined, + prevName: undefined, + }), + ); }); describe('with file in directory', () => { @@ -919,24 +818,21 @@ describe('Multi-file store actions', () => { }); }); - it('creates new directory', (done) => { + it('creates new directory', async () => { expect(store.state.entries[newParentPath]).toBeUndefined(); - store - .dispatch('renameEntry', { path: filePath, name: fileName, parentPath: newParentPath }) - .then(() => { - expect(store.state.entries[newParentPath]).toEqual( - expect.objectContaining({ - path: newParentPath, - type: 'tree', - tree: expect.arrayContaining([ - store.state.entries[`${newParentPath}/${fileName}`], - ]), - }), - ); - }) - .then(done) - .catch(done.fail); + await store.dispatch('renameEntry', { + path: filePath, + name: fileName, + parentPath: newParentPath, + }); + expect(store.state.entries[newParentPath]).toEqual( + expect.objectContaining({ + path: newParentPath, + type: 'tree', + tree: expect.arrayContaining([store.state.entries[`${newParentPath}/${fileName}`]]), + }), + ); }); describe('when new directory exists', () => { @@ -949,40 +845,30 @@ describe('Multi-file store actions', () => { rootDir.tree.push(newDir); }); - it('inserts in new directory', (done) => { + it('inserts in new directory', async () => { expect(newDir.tree).toEqual([]); - store - .dispatch('renameEntry', { - path: filePath, - name: fileName, - parentPath: newParentPath, - }) - .then(() => { - expect(newDir.tree).toEqual([store.state.entries[`${newParentPath}/${fileName}`]]); - }) - .then(done) - .catch(done.fail); + await store.dispatch('renameEntry', { + path: filePath, + name: fileName, + parentPath: newParentPath, + }); + expect(newDir.tree).toEqual([store.state.entries[`${newParentPath}/${fileName}`]]); }); - it('when new directory is deleted, it undeletes it', (done) => { - store.dispatch('deleteEntry', newParentPath); + it('when new directory is deleted, it undeletes it', async () => { + await store.dispatch('deleteEntry', newParentPath); expect(store.state.entries[newParentPath].deleted).toBe(true); expect(rootDir.tree.some((x) => x.path === newParentPath)).toBe(false); - store - .dispatch('renameEntry', { - path: filePath, - name: fileName, - parentPath: newParentPath, - }) - .then(() => { - expect(store.state.entries[newParentPath].deleted).toBe(false); - expect(rootDir.tree.some((x) => x.path === newParentPath)).toBe(true); - }) - .then(done) - .catch(done.fail); + await store.dispatch('renameEntry', { + path: filePath, + name: fileName, + parentPath: newParentPath, + }); + expect(store.state.entries[newParentPath].deleted).toBe(false); + expect(rootDir.tree.some((x) => x.path === newParentPath)).toBe(true); }); }); }); @@ -1023,30 +909,25 @@ describe('Multi-file store actions', () => { document.querySelector('.flash-container').remove(); }); - it('passes the error further unchanged without dispatching any action when response is 404', (done) => { + it('passes the error further unchanged without dispatching any action when response is 404', async () => { mock.onGet(/(.*)/).replyOnce(404); - getBranchData(...callParams) - .then(done.fail) - .catch((e) => { - expect(dispatch.mock.calls).toHaveLength(0); - expect(e.response.status).toEqual(404); - expect(document.querySelector('.flash-alert')).toBeNull(); - done(); - }); + await expect(getBranchData(...callParams)).rejects.toEqual( + new Error('Request failed with status code 404'), + ); + expect(dispatch.mock.calls).toHaveLength(0); + expect(document.querySelector('.flash-alert')).toBeNull(); }); - it('does not pass the error further and flashes an alert if error is not 404', (done) => { + it('does not pass the error further and flashes an alert if error is not 404', async () => { mock.onGet(/(.*)/).replyOnce(418); - getBranchData(...callParams) - .then(done.fail) - .catch((e) => { - expect(dispatch.mock.calls).toHaveLength(0); - expect(e.response).toBeUndefined(); - expect(document.querySelector('.flash-alert')).not.toBeNull(); - done(); - }); + await expect(getBranchData(...callParams)).rejects.toEqual( + new Error('Branch not loaded - <strong>abc/def/main-testing</strong>'), + ); + + expect(dispatch.mock.calls).toHaveLength(0); + expect(document.querySelector('.flash-alert')).not.toBeNull(); }); }); }); diff --git a/spec/frontend/ide/stores/modules/branches/actions_spec.js b/spec/frontend/ide/stores/modules/branches/actions_spec.js index 135dbc1f746..306330e3ba2 100644 --- a/spec/frontend/ide/stores/modules/branches/actions_spec.js +++ b/spec/frontend/ide/stores/modules/branches/actions_spec.js @@ -42,21 +42,20 @@ describe('IDE branches actions', () => { }); describe('requestBranches', () => { - it('should commit request', (done) => { - testAction( + it('should commit request', () => { + return testAction( requestBranches, null, mockedContext.state, [{ type: types.REQUEST_BRANCHES }], [], - done, ); }); }); describe('receiveBranchesError', () => { - it('should commit error', (done) => { - testAction( + it('should commit error', () => { + return testAction( receiveBranchesError, { search: TEST_SEARCH }, mockedContext.state, @@ -72,20 +71,18 @@ describe('IDE branches actions', () => { }, }, ], - done, ); }); }); describe('receiveBranchesSuccess', () => { - it('should commit received data', (done) => { - testAction( + it('should commit received data', () => { + return testAction( receiveBranchesSuccess, branches, mockedContext.state, [{ type: types.RECEIVE_BRANCHES_SUCCESS, payload: branches }], [], - done, ); }); }); @@ -110,8 +107,8 @@ describe('IDE branches actions', () => { }); }); - it('dispatches success with received data', (done) => { - testAction( + it('dispatches success with received data', () => { + return testAction( fetchBranches, { search: TEST_SEARCH }, mockedState, @@ -121,7 +118,6 @@ describe('IDE branches actions', () => { { type: 'resetBranches' }, { type: 'receiveBranchesSuccess', payload: branches }, ], - done, ); }); }); @@ -131,8 +127,8 @@ describe('IDE branches actions', () => { mock.onGet(/\/api\/v4\/projects\/\d+\/repository\/branches(.*)$/).replyOnce(500); }); - it('dispatches error', (done) => { - testAction( + it('dispatches error', () => { + return testAction( fetchBranches, { search: TEST_SEARCH }, mockedState, @@ -142,20 +138,18 @@ describe('IDE branches actions', () => { { type: 'resetBranches' }, { type: 'receiveBranchesError', payload: { search: TEST_SEARCH } }, ], - done, ); }); }); describe('resetBranches', () => { - it('commits reset', (done) => { - testAction( + it('commits reset', () => { + return testAction( resetBranches, null, mockedContext.state, [{ type: types.RESET_BRANCHES }], [], - done, ); }); }); diff --git a/spec/frontend/ide/stores/modules/clientside/actions_spec.js b/spec/frontend/ide/stores/modules/clientside/actions_spec.js index d2777623b0d..c2b9de192d9 100644 --- a/spec/frontend/ide/stores/modules/clientside/actions_spec.js +++ b/spec/frontend/ide/stores/modules/clientside/actions_spec.js @@ -26,15 +26,13 @@ describe('IDE store module clientside actions', () => { }); describe('pingUsage', () => { - it('posts to usage endpoint', (done) => { + it('posts to usage endpoint', async () => { const usageSpy = jest.fn(() => [200]); mock.onPost(TEST_USAGE_URL).reply(() => usageSpy()); - testAction(actions.pingUsage, PING_USAGE_PREVIEW_KEY, rootGetters, [], [], () => { - expect(usageSpy).toHaveBeenCalled(); - done(); - }); + await testAction(actions.pingUsage, PING_USAGE_PREVIEW_KEY, rootGetters, [], []); + expect(usageSpy).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/ide/stores/modules/commit/actions_spec.js b/spec/frontend/ide/stores/modules/commit/actions_spec.js index cb6bb7c1202..d65039e89cc 100644 --- a/spec/frontend/ide/stores/modules/commit/actions_spec.js +++ b/spec/frontend/ide/stores/modules/commit/actions_spec.js @@ -57,40 +57,25 @@ describe('IDE commit module actions', () => { }); describe('updateCommitMessage', () => { - it('updates store with new commit message', (done) => { - store - .dispatch('commit/updateCommitMessage', 'testing') - .then(() => { - expect(store.state.commit.commitMessage).toBe('testing'); - }) - .then(done) - .catch(done.fail); + it('updates store with new commit message', async () => { + await store.dispatch('commit/updateCommitMessage', 'testing'); + expect(store.state.commit.commitMessage).toBe('testing'); }); }); describe('discardDraft', () => { - it('resets commit message to blank', (done) => { + it('resets commit message to blank', async () => { store.state.commit.commitMessage = 'testing'; - store - .dispatch('commit/discardDraft') - .then(() => { - expect(store.state.commit.commitMessage).not.toBe('testing'); - }) - .then(done) - .catch(done.fail); + await store.dispatch('commit/discardDraft'); + expect(store.state.commit.commitMessage).not.toBe('testing'); }); }); describe('updateCommitAction', () => { - it('updates store with new commit action', (done) => { - store - .dispatch('commit/updateCommitAction', '1') - .then(() => { - expect(store.state.commit.commitAction).toBe('1'); - }) - .then(done) - .catch(done.fail); + it('updates store with new commit action', async () => { + await store.dispatch('commit/updateCommitAction', '1'); + expect(store.state.commit.commitAction).toBe('1'); }); }); @@ -139,34 +124,24 @@ describe('IDE commit module actions', () => { }); }); - it('updates commit message with short_id', (done) => { - store - .dispatch('commit/setLastCommitMessage', { short_id: '123' }) - .then(() => { - expect(store.state.lastCommitMsg).toContain( - 'Your changes have been committed. Commit <a href="http://testing/-/commit/123" class="commit-sha">123</a>', - ); - }) - .then(done) - .catch(done.fail); + it('updates commit message with short_id', async () => { + await store.dispatch('commit/setLastCommitMessage', { short_id: '123' }); + expect(store.state.lastCommitMsg).toContain( + 'Your changes have been committed. Commit <a href="http://testing/-/commit/123" class="commit-sha">123</a>', + ); }); - it('updates commit message with stats', (done) => { - store - .dispatch('commit/setLastCommitMessage', { - short_id: '123', - stats: { - additions: '1', - deletions: '2', - }, - }) - .then(() => { - expect(store.state.lastCommitMsg).toBe( - 'Your changes have been committed. Commit <a href="http://testing/-/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.', - ); - }) - .then(done) - .catch(done.fail); + it('updates commit message with stats', async () => { + await store.dispatch('commit/setLastCommitMessage', { + short_id: '123', + stats: { + additions: '1', + deletions: '2', + }, + }); + expect(store.state.lastCommitMsg).toBe( + 'Your changes have been committed. Commit <a href="http://testing/-/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.', + ); }); }); @@ -221,74 +196,49 @@ describe('IDE commit module actions', () => { }); }); - it('updates stores working reference', (done) => { - store - .dispatch('commit/updateFilesAfterCommit', { - data, - branch, - }) - .then(() => { - expect(store.state.projects.abcproject.branches.main.workingReference).toBe(data.id); - }) - .then(done) - .catch(done.fail); + it('updates stores working reference', async () => { + await store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }); + expect(store.state.projects.abcproject.branches.main.workingReference).toBe(data.id); }); - it('resets all files changed status', (done) => { - store - .dispatch('commit/updateFilesAfterCommit', { - data, - branch, - }) - .then(() => { - store.state.openFiles.forEach((entry) => { - expect(entry.changed).toBeFalsy(); - }); - }) - .then(done) - .catch(done.fail); + it('resets all files changed status', async () => { + await store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }); + store.state.openFiles.forEach((entry) => { + expect(entry.changed).toBeFalsy(); + }); }); - it('sets files commit data', (done) => { - store - .dispatch('commit/updateFilesAfterCommit', { - data, - branch, - }) - .then(() => { - expect(f.lastCommitSha).toBe(data.id); - }) - .then(done) - .catch(done.fail); + it('sets files commit data', async () => { + await store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }); + expect(f.lastCommitSha).toBe(data.id); }); - it('updates raw content for changed file', (done) => { - store - .dispatch('commit/updateFilesAfterCommit', { - data, - branch, - }) - .then(() => { - expect(f.raw).toBe(f.content); - }) - .then(done) - .catch(done.fail); + it('updates raw content for changed file', async () => { + await store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }); + expect(f.raw).toBe(f.content); }); - it('emits changed event for file', (done) => { - store - .dispatch('commit/updateFilesAfterCommit', { - data, - branch, - }) - .then(() => { - expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.content.${f.key}`, { - content: f.content, - changed: false, - }); - }) - .then(done) - .catch(done.fail); + it('emits changed event for file', async () => { + await store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }); + expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.content.${f.key}`, { + content: f.content, + changed: false, + }); }); }); @@ -349,138 +299,93 @@ describe('IDE commit module actions', () => { jest.spyOn(service, 'commit').mockResolvedValue({ data: COMMIT_RESPONSE }); }); - it('calls service', (done) => { - store - .dispatch('commit/commitChanges') - .then(() => { - expect(service.commit).toHaveBeenCalledWith('abcproject', { - branch: expect.anything(), - commit_message: 'testing 123', - actions: [ - { - action: commitActionTypes.update, - file_path: expect.anything(), - content: '\n', - encoding: expect.anything(), - last_commit_id: undefined, - previous_path: undefined, - }, - ], - start_sha: TEST_COMMIT_SHA, - }); - - done(); - }) - .catch(done.fail); + it('calls service', async () => { + await store.dispatch('commit/commitChanges'); + expect(service.commit).toHaveBeenCalledWith('abcproject', { + branch: expect.anything(), + commit_message: 'testing 123', + actions: [ + { + action: commitActionTypes.update, + file_path: expect.anything(), + content: '\n', + encoding: expect.anything(), + last_commit_id: undefined, + previous_path: undefined, + }, + ], + start_sha: TEST_COMMIT_SHA, + }); }); - it('sends lastCommit ID when not creating new branch', (done) => { + it('sends lastCommit ID when not creating new branch', async () => { store.state.commit.commitAction = '1'; - store - .dispatch('commit/commitChanges') - .then(() => { - expect(service.commit).toHaveBeenCalledWith('abcproject', { - branch: expect.anything(), - commit_message: 'testing 123', - actions: [ - { - action: commitActionTypes.update, - file_path: expect.anything(), - content: '\n', - encoding: expect.anything(), - last_commit_id: TEST_COMMIT_SHA, - previous_path: undefined, - }, - ], - start_sha: undefined, - }); - - done(); - }) - .catch(done.fail); + await store.dispatch('commit/commitChanges'); + expect(service.commit).toHaveBeenCalledWith('abcproject', { + branch: expect.anything(), + commit_message: 'testing 123', + actions: [ + { + action: commitActionTypes.update, + file_path: expect.anything(), + content: '\n', + encoding: expect.anything(), + last_commit_id: TEST_COMMIT_SHA, + previous_path: undefined, + }, + ], + start_sha: undefined, + }); }); - it('sets last Commit Msg', (done) => { - store - .dispatch('commit/commitChanges') - .then(() => { - expect(store.state.lastCommitMsg).toBe( - 'Your changes have been committed. Commit <a href="webUrl/-/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.', - ); - - done(); - }) - .catch(done.fail); + it('sets last Commit Msg', async () => { + await store.dispatch('commit/commitChanges'); + expect(store.state.lastCommitMsg).toBe( + 'Your changes have been committed. Commit <a href="webUrl/-/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.', + ); }); - it('adds commit data to files', (done) => { - store - .dispatch('commit/commitChanges') - .then(() => { - expect(store.state.entries[store.state.openFiles[0].path].lastCommitSha).toBe( - COMMIT_RESPONSE.id, - ); - - done(); - }) - .catch(done.fail); + it('adds commit data to files', async () => { + await store.dispatch('commit/commitChanges'); + expect(store.state.entries[store.state.openFiles[0].path].lastCommitSha).toBe( + COMMIT_RESPONSE.id, + ); }); - it('resets stores commit actions', (done) => { + it('resets stores commit actions', async () => { store.state.commit.commitAction = COMMIT_TO_NEW_BRANCH; - store - .dispatch('commit/commitChanges') - .then(() => { - expect(store.state.commit.commitAction).not.toBe(COMMIT_TO_NEW_BRANCH); - }) - .then(done) - .catch(done.fail); + await store.dispatch('commit/commitChanges'); + expect(store.state.commit.commitAction).not.toBe(COMMIT_TO_NEW_BRANCH); }); - it('removes all staged files', (done) => { - store - .dispatch('commit/commitChanges') - .then(() => { - expect(store.state.stagedFiles.length).toBe(0); - }) - .then(done) - .catch(done.fail); + it('removes all staged files', async () => { + await store.dispatch('commit/commitChanges'); + expect(store.state.stagedFiles.length).toBe(0); }); describe('merge request', () => { - it('redirects to new merge request page', (done) => { + it('redirects to new merge request page', async () => { jest.spyOn(eventHub, '$on').mockImplementation(); store.state.commit.commitAction = COMMIT_TO_NEW_BRANCH; store.state.commit.shouldCreateMR = true; - store - .dispatch('commit/commitChanges') - .then(() => { - expect(visitUrl).toHaveBeenCalledWith( - `webUrl/-/merge_requests/new?merge_request[source_branch]=${store.getters['commit/placeholderBranchName']}&merge_request[target_branch]=main&nav_source=webide`, - ); - - done(); - }) - .catch(done.fail); + await store.dispatch('commit/commitChanges'); + expect(visitUrl).toHaveBeenCalledWith( + `webUrl/-/merge_requests/new?merge_request[source_branch]=${store.getters['commit/placeholderBranchName']}&merge_request[target_branch]=main&nav_source=webide`, + ); }); - it('does not redirect to new merge request page when shouldCreateMR is not checked', (done) => { + it('does not redirect to new merge request page when shouldCreateMR is not checked', async () => { jest.spyOn(eventHub, '$on').mockImplementation(); store.state.commit.commitAction = COMMIT_TO_NEW_BRANCH; store.state.commit.shouldCreateMR = false; - store - .dispatch('commit/commitChanges') - .then(() => { - expect(visitUrl).not.toHaveBeenCalled(); - done(); - }) - .catch(done.fail); + await store.dispatch('commit/commitChanges'); + expect(visitUrl).not.toHaveBeenCalled(); }); it('does not redirect to merge request page if shouldCreateMR is checked, but branch is the default branch', async () => { @@ -514,17 +419,11 @@ describe('IDE commit module actions', () => { }); }); - it('shows failed message', (done) => { - store - .dispatch('commit/commitChanges') - .then(() => { - const alert = document.querySelector('.flash-container'); - - expect(alert.textContent.trim()).toBe('failed message'); + it('shows failed message', async () => { + await store.dispatch('commit/commitChanges'); + const alert = document.querySelector('.flash-container'); - done(); - }) - .catch(done.fail); + expect(alert.textContent.trim()).toBe('failed message'); }); }); @@ -548,52 +447,37 @@ describe('IDE commit module actions', () => { }); describe('first commit of a branch', () => { - it('commits TOGGLE_EMPTY_STATE mutation on empty repo', (done) => { + it('commits TOGGLE_EMPTY_STATE mutation on empty repo', async () => { jest.spyOn(service, 'commit').mockResolvedValue({ data: COMMIT_RESPONSE }); jest.spyOn(store, 'commit'); - store - .dispatch('commit/commitChanges') - .then(() => { - expect(store.commit.mock.calls).toEqual( - expect.arrayContaining([ - ['TOGGLE_EMPTY_STATE', expect.any(Object), expect.any(Object)], - ]), - ); - done(); - }) - .catch(done.fail); + await store.dispatch('commit/commitChanges'); + expect(store.commit.mock.calls).toEqual( + expect.arrayContaining([['TOGGLE_EMPTY_STATE', expect.any(Object), expect.any(Object)]]), + ); }); - it('does not commmit TOGGLE_EMPTY_STATE mutation on existing project', (done) => { + it('does not commmit TOGGLE_EMPTY_STATE mutation on existing project', async () => { COMMIT_RESPONSE.parent_ids.push('1234'); jest.spyOn(service, 'commit').mockResolvedValue({ data: COMMIT_RESPONSE }); jest.spyOn(store, 'commit'); - store - .dispatch('commit/commitChanges') - .then(() => { - expect(store.commit.mock.calls).not.toEqual( - expect.arrayContaining([ - ['TOGGLE_EMPTY_STATE', expect.any(Object), expect.any(Object)], - ]), - ); - done(); - }) - .catch(done.fail); + await store.dispatch('commit/commitChanges'); + expect(store.commit.mock.calls).not.toEqual( + expect.arrayContaining([['TOGGLE_EMPTY_STATE', expect.any(Object), expect.any(Object)]]), + ); }); }); }); describe('toggleShouldCreateMR', () => { - it('commits both toggle and interacting with MR checkbox actions', (done) => { - testAction( + it('commits both toggle and interacting with MR checkbox actions', () => { + return testAction( actions.toggleShouldCreateMR, {}, store.state, [{ type: mutationTypes.TOGGLE_SHOULD_CREATE_MR }], [], - done, ); }); }); diff --git a/spec/frontend/ide/stores/modules/file_templates/actions_spec.js b/spec/frontend/ide/stores/modules/file_templates/actions_spec.js index 9ff950b0875..1080a30d2d8 100644 --- a/spec/frontend/ide/stores/modules/file_templates/actions_spec.js +++ b/spec/frontend/ide/stores/modules/file_templates/actions_spec.js @@ -20,21 +20,20 @@ describe('IDE file templates actions', () => { }); describe('requestTemplateTypes', () => { - it('commits REQUEST_TEMPLATE_TYPES', (done) => { - testAction( + it('commits REQUEST_TEMPLATE_TYPES', () => { + return testAction( actions.requestTemplateTypes, null, state, [{ type: types.REQUEST_TEMPLATE_TYPES }], [], - done, ); }); }); describe('receiveTemplateTypesError', () => { - it('commits RECEIVE_TEMPLATE_TYPES_ERROR and dispatches setErrorMessage', (done) => { - testAction( + it('commits RECEIVE_TEMPLATE_TYPES_ERROR and dispatches setErrorMessage', () => { + return testAction( actions.receiveTemplateTypesError, null, state, @@ -49,20 +48,18 @@ describe('IDE file templates actions', () => { }, }, ], - done, ); }); }); describe('receiveTemplateTypesSuccess', () => { - it('commits RECEIVE_TEMPLATE_TYPES_SUCCESS', (done) => { - testAction( + it('commits RECEIVE_TEMPLATE_TYPES_SUCCESS', () => { + return testAction( actions.receiveTemplateTypesSuccess, 'test', state, [{ type: types.RECEIVE_TEMPLATE_TYPES_SUCCESS, payload: 'test' }], [], - done, ); }); }); @@ -81,23 +78,17 @@ describe('IDE file templates actions', () => { }); }); - it('rejects if selectedTemplateType is empty', (done) => { + it('rejects if selectedTemplateType is empty', async () => { const dispatch = jest.fn().mockName('dispatch'); - actions - .fetchTemplateTypes({ dispatch, state }) - .then(done.fail) - .catch(() => { - expect(dispatch).not.toHaveBeenCalled(); - - done(); - }); + await expect(actions.fetchTemplateTypes({ dispatch, state })).rejects.toBeUndefined(); + expect(dispatch).not.toHaveBeenCalled(); }); - it('dispatches actions', (done) => { + it('dispatches actions', () => { state.selectedTemplateType = { key: 'licenses' }; - testAction( + return testAction( actions.fetchTemplateTypes, null, state, @@ -111,7 +102,6 @@ describe('IDE file templates actions', () => { payload: pages[0].concat(pages[1]).concat(pages[2]), }, ], - done, ); }); }); @@ -121,16 +111,15 @@ describe('IDE file templates actions', () => { mock.onGet(/api\/(.*)\/templates\/licenses/).replyOnce(500); }); - it('dispatches actions', (done) => { + it('dispatches actions', () => { state.selectedTemplateType = { key: 'licenses' }; - testAction( + return testAction( actions.fetchTemplateTypes, null, state, [], [{ type: 'requestTemplateTypes' }, { type: 'receiveTemplateTypesError' }], - done, ); }); }); @@ -184,8 +173,8 @@ describe('IDE file templates actions', () => { }); describe('receiveTemplateError', () => { - it('dispatches setErrorMessage', (done) => { - testAction( + it('dispatches setErrorMessage', () => { + return testAction( actions.receiveTemplateError, 'test', state, @@ -201,7 +190,6 @@ describe('IDE file templates actions', () => { }, }, ], - done, ); }); }); @@ -217,46 +205,43 @@ describe('IDE file templates actions', () => { .replyOnce(200, { content: 'testing content' }); }); - it('dispatches setFileTemplate if template already has content', (done) => { + it('dispatches setFileTemplate if template already has content', () => { const template = { content: 'already has content' }; - testAction( + return testAction( actions.fetchTemplate, template, state, [], [{ type: 'setFileTemplate', payload: template }], - done, ); }); - it('dispatches success', (done) => { + it('dispatches success', () => { const template = { key: 'mit' }; state.selectedTemplateType = { key: 'licenses' }; - testAction( + return testAction( actions.fetchTemplate, template, state, [], [{ type: 'setFileTemplate', payload: { content: 'MIT content' } }], - done, ); }); - it('dispatches success and uses name key for API call', (done) => { + it('dispatches success and uses name key for API call', () => { const template = { name: 'testing' }; state.selectedTemplateType = { key: 'licenses' }; - testAction( + return testAction( actions.fetchTemplate, template, state, [], [{ type: 'setFileTemplate', payload: { content: 'testing content' } }], - done, ); }); }); @@ -266,18 +251,17 @@ describe('IDE file templates actions', () => { mock.onGet(/api\/(.*)\/templates\/licenses\/mit/).replyOnce(500); }); - it('dispatches error', (done) => { + it('dispatches error', () => { const template = { name: 'testing' }; state.selectedTemplateType = { key: 'licenses' }; - testAction( + return testAction( actions.fetchTemplate, template, state, [], [{ type: 'receiveTemplateError', payload: template }], - done, ); }); }); diff --git a/spec/frontend/ide/stores/modules/merge_requests/actions_spec.js b/spec/frontend/ide/stores/modules/merge_requests/actions_spec.js index e1f2b165dd9..344fe3a41c3 100644 --- a/spec/frontend/ide/stores/modules/merge_requests/actions_spec.js +++ b/spec/frontend/ide/stores/modules/merge_requests/actions_spec.js @@ -28,21 +28,20 @@ describe('IDE merge requests actions', () => { }); describe('requestMergeRequests', () => { - it('should commit request', (done) => { - testAction( + it('should commit request', () => { + return testAction( requestMergeRequests, null, mockedState, [{ type: types.REQUEST_MERGE_REQUESTS }], [], - done, ); }); }); describe('receiveMergeRequestsError', () => { - it('should commit error', (done) => { - testAction( + it('should commit error', () => { + return testAction( receiveMergeRequestsError, { type: 'created', search: '' }, mockedState, @@ -58,20 +57,18 @@ describe('IDE merge requests actions', () => { }, }, ], - done, ); }); }); describe('receiveMergeRequestsSuccess', () => { - it('should commit received data', (done) => { - testAction( + it('should commit received data', () => { + return testAction( receiveMergeRequestsSuccess, mergeRequests, mockedState, [{ type: types.RECEIVE_MERGE_REQUESTS_SUCCESS, payload: mergeRequests }], [], - done, ); }); }); @@ -118,8 +115,8 @@ describe('IDE merge requests actions', () => { }); }); - it('dispatches success with received data', (done) => { - testAction( + it('dispatches success with received data', () => { + return testAction( fetchMergeRequests, { type: 'created' }, mockedState, @@ -129,7 +126,6 @@ describe('IDE merge requests actions', () => { { type: 'resetMergeRequests' }, { type: 'receiveMergeRequestsSuccess', payload: mergeRequests }, ], - done, ); }); }); @@ -156,8 +152,8 @@ describe('IDE merge requests actions', () => { ); }); - it('dispatches success with received data', (done) => { - testAction( + it('dispatches success with received data', () => { + return testAction( fetchMergeRequests, { type: null }, { ...mockedState, ...mockedRootState }, @@ -167,7 +163,6 @@ describe('IDE merge requests actions', () => { { type: 'resetMergeRequests' }, { type: 'receiveMergeRequestsSuccess', payload: mergeRequests }, ], - done, ); }); }); @@ -177,8 +172,8 @@ describe('IDE merge requests actions', () => { mock.onGet(/\/api\/v4\/merge_requests(.*)$/).replyOnce(500); }); - it('dispatches error', (done) => { - testAction( + it('dispatches error', () => { + return testAction( fetchMergeRequests, { type: 'created', search: '' }, mockedState, @@ -188,21 +183,19 @@ describe('IDE merge requests actions', () => { { type: 'resetMergeRequests' }, { type: 'receiveMergeRequestsError', payload: { type: 'created', search: '' } }, ], - done, ); }); }); }); describe('resetMergeRequests', () => { - it('commits reset', (done) => { - testAction( + it('commits reset', () => { + return testAction( resetMergeRequests, null, mockedState, [{ type: types.RESET_MERGE_REQUESTS }], [], - done, ); }); }); diff --git a/spec/frontend/ide/stores/modules/pane/actions_spec.js b/spec/frontend/ide/stores/modules/pane/actions_spec.js index 42fe8b400b8..98c4f22dac8 100644 --- a/spec/frontend/ide/stores/modules/pane/actions_spec.js +++ b/spec/frontend/ide/stores/modules/pane/actions_spec.js @@ -7,19 +7,19 @@ describe('IDE pane module actions', () => { const TEST_VIEW_KEEP_ALIVE = { name: 'test-keep-alive', keepAlive: true }; describe('toggleOpen', () => { - it('dispatches open if closed', (done) => { - testAction(actions.toggleOpen, TEST_VIEW, { isOpen: false }, [], [{ type: 'open' }], done); + it('dispatches open if closed', () => { + return testAction(actions.toggleOpen, TEST_VIEW, { isOpen: false }, [], [{ type: 'open' }]); }); - it('dispatches close if opened', (done) => { - testAction(actions.toggleOpen, TEST_VIEW, { isOpen: true }, [], [{ type: 'close' }], done); + it('dispatches close if opened', () => { + return testAction(actions.toggleOpen, TEST_VIEW, { isOpen: true }, [], [{ type: 'close' }]); }); }); describe('open', () => { describe('with a view specified', () => { - it('commits SET_OPEN and SET_CURRENT_VIEW', (done) => { - testAction( + it('commits SET_OPEN and SET_CURRENT_VIEW', () => { + return testAction( actions.open, TEST_VIEW, {}, @@ -28,12 +28,11 @@ describe('IDE pane module actions', () => { { type: types.SET_CURRENT_VIEW, payload: TEST_VIEW.name }, ], [], - done, ); }); - it('commits KEEP_ALIVE_VIEW if keepAlive is true', (done) => { - testAction( + it('commits KEEP_ALIVE_VIEW if keepAlive is true', () => { + return testAction( actions.open, TEST_VIEW_KEEP_ALIVE, {}, @@ -43,28 +42,26 @@ describe('IDE pane module actions', () => { { type: types.KEEP_ALIVE_VIEW, payload: TEST_VIEW_KEEP_ALIVE.name }, ], [], - done, ); }); }); describe('without a view specified', () => { - it('commits SET_OPEN', (done) => { - testAction( + it('commits SET_OPEN', () => { + return testAction( actions.open, undefined, {}, [{ type: types.SET_OPEN, payload: true }], [], - done, ); }); }); }); describe('close', () => { - it('commits SET_OPEN', (done) => { - testAction(actions.close, null, {}, [{ type: types.SET_OPEN, payload: false }], [], done); + it('commits SET_OPEN', () => { + return testAction(actions.close, null, {}, [{ type: types.SET_OPEN, payload: false }], []); }); }); }); diff --git a/spec/frontend/ide/stores/modules/pipelines/actions_spec.js b/spec/frontend/ide/stores/modules/pipelines/actions_spec.js index 3ede37e2eed..b76b673c3a2 100644 --- a/spec/frontend/ide/stores/modules/pipelines/actions_spec.js +++ b/spec/frontend/ide/stores/modules/pipelines/actions_spec.js @@ -25,6 +25,7 @@ import { import * as types from '~/ide/stores/modules/pipelines/mutation_types'; import state from '~/ide/stores/modules/pipelines/state'; import axios from '~/lib/utils/axios_utils'; +import waitForPromises from 'helpers/wait_for_promises'; import { pipelines, jobs } from '../../../mock_data'; describe('IDE pipelines actions', () => { @@ -44,32 +45,30 @@ describe('IDE pipelines actions', () => { }); describe('requestLatestPipeline', () => { - it('commits request', (done) => { - testAction( + it('commits request', () => { + return testAction( requestLatestPipeline, null, mockedState, [{ type: types.REQUEST_LATEST_PIPELINE }], [], - done, ); }); }); describe('receiveLatestPipelineError', () => { - it('commits error', (done) => { - testAction( + it('commits error', () => { + return testAction( receiveLatestPipelineError, { status: 404 }, mockedState, [{ type: types.RECEIVE_LASTEST_PIPELINE_ERROR }], [{ type: 'stopPipelinePolling' }], - done, ); }); - it('dispatches setErrorMessage is not 404', (done) => { - testAction( + it('dispatches setErrorMessage is not 404', () => { + return testAction( receiveLatestPipelineError, { status: 500 }, mockedState, @@ -86,7 +85,6 @@ describe('IDE pipelines actions', () => { }, { type: 'stopPipelinePolling' }, ], - done, ); }); }); @@ -123,7 +121,7 @@ describe('IDE pipelines actions', () => { .reply(200, { data: { foo: 'bar' } }, { 'poll-interval': '10000' }); }); - it('dispatches request', (done) => { + it('dispatches request', async () => { jest.spyOn(axios, 'get'); jest.spyOn(Visibility, 'hidden').mockReturnValue(false); @@ -133,34 +131,21 @@ describe('IDE pipelines actions', () => { currentProject: { path_with_namespace: 'abc/def' }, }; - fetchLatestPipeline({ dispatch, rootGetters }); + await fetchLatestPipeline({ dispatch, rootGetters }); expect(dispatch).toHaveBeenCalledWith('requestLatestPipeline'); - jest.advanceTimersByTime(1000); - - new Promise((resolve) => requestAnimationFrame(resolve)) - .then(() => { - expect(axios.get).toHaveBeenCalled(); - expect(axios.get).toHaveBeenCalledTimes(1); - expect(dispatch).toHaveBeenCalledWith( - 'receiveLatestPipelineSuccess', - expect.anything(), - ); - - jest.advanceTimersByTime(10000); - }) - .then(() => new Promise((resolve) => requestAnimationFrame(resolve))) - .then(() => { - expect(axios.get).toHaveBeenCalled(); - expect(axios.get).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenCalledWith( - 'receiveLatestPipelineSuccess', - expect.anything(), - ); - }) - .then(done) - .catch(done.fail); + await waitForPromises(); + + expect(axios.get).toHaveBeenCalled(); + expect(axios.get).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith('receiveLatestPipelineSuccess', expect.anything()); + + jest.advanceTimersByTime(10000); + + expect(axios.get).toHaveBeenCalled(); + expect(axios.get).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenCalledWith('receiveLatestPipelineSuccess', expect.anything()); }); }); @@ -169,27 +154,22 @@ describe('IDE pipelines actions', () => { mock.onGet('/abc/def/commit/abc123def456ghi789jkl/pipelines').reply(500); }); - it('dispatches error', (done) => { + it('dispatches error', async () => { const dispatch = jest.fn().mockName('dispatch'); const rootGetters = { lastCommit: { id: 'abc123def456ghi789jkl' }, currentProject: { path_with_namespace: 'abc/def' }, }; - fetchLatestPipeline({ dispatch, rootGetters }); + await fetchLatestPipeline({ dispatch, rootGetters }); - jest.advanceTimersByTime(1500); + await waitForPromises(); - new Promise((resolve) => requestAnimationFrame(resolve)) - .then(() => { - expect(dispatch).toHaveBeenCalledWith('receiveLatestPipelineError', expect.anything()); - }) - .then(done) - .catch(done.fail); + expect(dispatch).toHaveBeenCalledWith('receiveLatestPipelineError', expect.anything()); }); }); - it('sets latest pipeline to `null` and stops polling on empty project', (done) => { + it('sets latest pipeline to `null` and stops polling on empty project', () => { mockedState = { ...mockedState, rootGetters: { @@ -197,26 +177,31 @@ describe('IDE pipelines actions', () => { }, }; - testAction( + return testAction( fetchLatestPipeline, {}, mockedState, [{ type: types.RECEIVE_LASTEST_PIPELINE_SUCCESS, payload: null }], [{ type: 'stopPipelinePolling' }], - done, ); }); }); describe('requestJobs', () => { - it('commits request', (done) => { - testAction(requestJobs, 1, mockedState, [{ type: types.REQUEST_JOBS, payload: 1 }], [], done); + it('commits request', () => { + return testAction( + requestJobs, + 1, + mockedState, + [{ type: types.REQUEST_JOBS, payload: 1 }], + [], + ); }); }); describe('receiveJobsError', () => { - it('commits error', (done) => { - testAction( + it('commits error', () => { + return testAction( receiveJobsError, { id: 1 }, mockedState, @@ -232,20 +217,18 @@ describe('IDE pipelines actions', () => { }, }, ], - done, ); }); }); describe('receiveJobsSuccess', () => { - it('commits data', (done) => { - testAction( + it('commits data', () => { + return testAction( receiveJobsSuccess, { id: 1, data: jobs }, mockedState, [{ type: types.RECEIVE_JOBS_SUCCESS, payload: { id: 1, data: jobs } }], [], - done, ); }); }); @@ -258,8 +241,8 @@ describe('IDE pipelines actions', () => { mock.onGet(stage.dropdownPath).replyOnce(200, jobs); }); - it('dispatches request', (done) => { - testAction( + it('dispatches request', () => { + return testAction( fetchJobs, stage, mockedState, @@ -268,7 +251,6 @@ describe('IDE pipelines actions', () => { { type: 'requestJobs', payload: stage.id }, { type: 'receiveJobsSuccess', payload: { id: stage.id, data: jobs } }, ], - done, ); }); }); @@ -278,8 +260,8 @@ describe('IDE pipelines actions', () => { mock.onGet(stage.dropdownPath).replyOnce(500); }); - it('dispatches error', (done) => { - testAction( + it('dispatches error', () => { + return testAction( fetchJobs, stage, mockedState, @@ -288,69 +270,64 @@ describe('IDE pipelines actions', () => { { type: 'requestJobs', payload: stage.id }, { type: 'receiveJobsError', payload: stage }, ], - done, ); }); }); }); describe('toggleStageCollapsed', () => { - it('commits collapse', (done) => { - testAction( + it('commits collapse', () => { + return testAction( toggleStageCollapsed, 1, mockedState, [{ type: types.TOGGLE_STAGE_COLLAPSE, payload: 1 }], [], - done, ); }); }); describe('setDetailJob', () => { - it('commits job', (done) => { - testAction( + it('commits job', () => { + return testAction( setDetailJob, 'job', mockedState, [{ type: types.SET_DETAIL_JOB, payload: 'job' }], [{ type: 'rightPane/open', payload: rightSidebarViews.jobsDetail }], - done, ); }); - it('dispatches rightPane/open as pipeline when job is null', (done) => { - testAction( + it('dispatches rightPane/open as pipeline when job is null', () => { + return testAction( setDetailJob, null, mockedState, [{ type: types.SET_DETAIL_JOB, payload: null }], [{ type: 'rightPane/open', payload: rightSidebarViews.pipelines }], - done, ); }); - it('dispatches rightPane/open as job', (done) => { - testAction( + it('dispatches rightPane/open as job', () => { + return testAction( setDetailJob, 'job', mockedState, [{ type: types.SET_DETAIL_JOB, payload: 'job' }], [{ type: 'rightPane/open', payload: rightSidebarViews.jobsDetail }], - done, ); }); }); describe('requestJobLogs', () => { - it('commits request', (done) => { - testAction(requestJobLogs, null, mockedState, [{ type: types.REQUEST_JOB_LOGS }], [], done); + it('commits request', () => { + return testAction(requestJobLogs, null, mockedState, [{ type: types.REQUEST_JOB_LOGS }], []); }); }); describe('receiveJobLogsError', () => { - it('commits error', (done) => { - testAction( + it('commits error', () => { + return testAction( receiveJobLogsError, null, mockedState, @@ -366,20 +343,18 @@ describe('IDE pipelines actions', () => { }, }, ], - done, ); }); }); describe('receiveJobLogsSuccess', () => { - it('commits data', (done) => { - testAction( + it('commits data', () => { + return testAction( receiveJobLogsSuccess, 'data', mockedState, [{ type: types.RECEIVE_JOB_LOGS_SUCCESS, payload: 'data' }], [], - done, ); }); }); @@ -395,8 +370,8 @@ describe('IDE pipelines actions', () => { mock.onGet(`${TEST_HOST}/project/builds/trace`).replyOnce(200, { html: 'html' }); }); - it('dispatches request', (done) => { - testAction( + it('dispatches request', () => { + return testAction( fetchJobLogs, null, mockedState, @@ -405,7 +380,6 @@ describe('IDE pipelines actions', () => { { type: 'requestJobLogs' }, { type: 'receiveJobLogsSuccess', payload: { html: 'html' } }, ], - done, ); }); @@ -426,22 +400,21 @@ describe('IDE pipelines actions', () => { mock.onGet(`${TEST_HOST}/project/builds/trace`).replyOnce(500); }); - it('dispatches error', (done) => { - testAction( + it('dispatches error', () => { + return testAction( fetchJobLogs, null, mockedState, [], [{ type: 'requestJobLogs' }, { type: 'receiveJobLogsError' }], - done, ); }); }); }); describe('resetLatestPipeline', () => { - it('commits reset mutations', (done) => { - testAction( + it('commits reset mutations', () => { + return testAction( resetLatestPipeline, null, mockedState, @@ -450,7 +423,6 @@ describe('IDE pipelines actions', () => { { type: types.SET_DETAIL_JOB, payload: null }, ], [], - done, ); }); }); diff --git a/spec/frontend/ide/stores/modules/terminal_sync/actions_spec.js b/spec/frontend/ide/stores/modules/terminal_sync/actions_spec.js index 22b0615c6d0..448fd909f39 100644 --- a/spec/frontend/ide/stores/modules/terminal_sync/actions_spec.js +++ b/spec/frontend/ide/stores/modules/terminal_sync/actions_spec.js @@ -22,43 +22,37 @@ describe('ide/stores/modules/terminal_sync/actions', () => { }); describe('upload', () => { - it('uploads to mirror and sets success', (done) => { + it('uploads to mirror and sets success', async () => { mirror.upload.mockReturnValue(Promise.resolve()); - testAction( + await testAction( actions.upload, null, rootState, [{ type: types.START_LOADING }, { type: types.SET_SUCCESS }], [], - () => { - expect(mirror.upload).toHaveBeenCalledWith(rootState); - done(); - }, ); + expect(mirror.upload).toHaveBeenCalledWith(rootState); }); - it('sets error when failed', (done) => { + it('sets error when failed', () => { const err = { message: 'it failed!' }; mirror.upload.mockReturnValue(Promise.reject(err)); - testAction( + return testAction( actions.upload, null, rootState, [{ type: types.START_LOADING }, { type: types.SET_ERROR, payload: err }], [], - done, ); }); }); describe('stop', () => { - it('disconnects from mirror', (done) => { - testAction(actions.stop, null, rootState, [{ type: types.STOP }], [], () => { - expect(mirror.disconnect).toHaveBeenCalled(); - done(); - }); + it('disconnects from mirror', async () => { + await testAction(actions.stop, null, rootState, [{ type: types.STOP }], []); + expect(mirror.disconnect).toHaveBeenCalled(); }); }); @@ -83,20 +77,17 @@ describe('ide/stores/modules/terminal_sync/actions', () => { }; }); - it('connects to mirror and sets success', (done) => { + it('connects to mirror and sets success', async () => { mirror.connect.mockReturnValue(Promise.resolve()); - testAction( + await testAction( actions.start, null, rootState, [{ type: types.START_LOADING }, { type: types.SET_SUCCESS }], [], - () => { - expect(mirror.connect).toHaveBeenCalledWith(TEST_SESSION.proxyWebsocketPath); - done(); - }, ); + expect(mirror.connect).toHaveBeenCalledWith(TEST_SESSION.proxyWebsocketPath); }); it('sets error if connection fails', () => { diff --git a/spec/frontend/ide/stores/plugins/terminal_spec.js b/spec/frontend/ide/stores/plugins/terminal_spec.js index 912de88cb39..193300540fd 100644 --- a/spec/frontend/ide/stores/plugins/terminal_spec.js +++ b/spec/frontend/ide/stores/plugins/terminal_spec.js @@ -6,10 +6,10 @@ import { SET_BRANCH_WORKING_REFERENCE } from '~/ide/stores/mutation_types'; import createTerminalPlugin from '~/ide/stores/plugins/terminal'; const TEST_DATASET = { - eeWebTerminalSvgPath: `${TEST_HOST}/web/terminal/svg`, - eeWebTerminalHelpPath: `${TEST_HOST}/web/terminal/help`, - eeWebTerminalConfigHelpPath: `${TEST_HOST}/web/terminal/config/help`, - eeWebTerminalRunnersHelpPath: `${TEST_HOST}/web/terminal/runners/help`, + webTerminalSvgPath: `${TEST_HOST}/web/terminal/svg`, + webTerminalHelpPath: `${TEST_HOST}/web/terminal/help`, + webTerminalConfigHelpPath: `${TEST_HOST}/web/terminal/config/help`, + webTerminalRunnersHelpPath: `${TEST_HOST}/web/terminal/runners/help`, }; Vue.use(Vuex); @@ -40,10 +40,10 @@ describe('ide/stores/extend', () => { it('dispatches terminal/setPaths', () => { expect(store.dispatch).toHaveBeenCalledWith('terminal/setPaths', { - webTerminalSvgPath: TEST_DATASET.eeWebTerminalSvgPath, - webTerminalHelpPath: TEST_DATASET.eeWebTerminalHelpPath, - webTerminalConfigHelpPath: TEST_DATASET.eeWebTerminalConfigHelpPath, - webTerminalRunnersHelpPath: TEST_DATASET.eeWebTerminalRunnersHelpPath, + webTerminalSvgPath: TEST_DATASET.webTerminalSvgPath, + webTerminalHelpPath: TEST_DATASET.webTerminalHelpPath, + webTerminalConfigHelpPath: TEST_DATASET.webTerminalConfigHelpPath, + webTerminalRunnersHelpPath: TEST_DATASET.webTerminalRunnersHelpPath, }); }); diff --git a/spec/frontend/image_diff/init_discussion_tab_spec.js b/spec/frontend/image_diff/init_discussion_tab_spec.js index 5bc0c738944..f6f05037c95 100644 --- a/spec/frontend/image_diff/init_discussion_tab_spec.js +++ b/spec/frontend/image_diff/init_discussion_tab_spec.js @@ -11,23 +11,21 @@ describe('initDiscussionTab', () => { `); }); - it('should pass canCreateNote as false to initImageDiff', (done) => { + it('should pass canCreateNote as false to initImageDiff', () => { jest .spyOn(initImageDiffHelper, 'initImageDiff') .mockImplementation((diffFileEl, canCreateNote) => { expect(canCreateNote).toEqual(false); - done(); }); initDiscussionTab(); }); - it('should pass renderCommentBadge as true to initImageDiff', (done) => { + it('should pass renderCommentBadge as true to initImageDiff', () => { jest .spyOn(initImageDiffHelper, 'initImageDiff') .mockImplementation((diffFileEl, canCreateNote, renderCommentBadge) => { expect(renderCommentBadge).toEqual(true); - done(); }); initDiscussionTab(); diff --git a/spec/frontend/image_diff/replaced_image_diff_spec.js b/spec/frontend/image_diff/replaced_image_diff_spec.js index cc4a2530fc4..2b401fc46bf 100644 --- a/spec/frontend/image_diff/replaced_image_diff_spec.js +++ b/spec/frontend/image_diff/replaced_image_diff_spec.js @@ -176,34 +176,36 @@ describe('ReplacedImageDiff', () => { expect(ImageDiff.prototype.bindEvents).toHaveBeenCalled(); }); - it('should register click eventlistener to 2-up view mode', (done) => { - jest.spyOn(ReplacedImageDiff.prototype, 'changeView').mockImplementation((viewMode) => { - expect(viewMode).toEqual(viewTypes.TWO_UP); - done(); - }); + it('should register click eventlistener to 2-up view mode', () => { + const changeViewSpy = jest + .spyOn(ReplacedImageDiff.prototype, 'changeView') + .mockImplementation(() => {}); replacedImageDiff.bindEvents(); replacedImageDiff.viewModesEls[viewTypes.TWO_UP].click(); + + expect(changeViewSpy).toHaveBeenCalledWith(viewTypes.TWO_UP, expect.any(Object)); }); - it('should register click eventlistener to swipe view mode', (done) => { - jest.spyOn(ReplacedImageDiff.prototype, 'changeView').mockImplementation((viewMode) => { - expect(viewMode).toEqual(viewTypes.SWIPE); - done(); - }); + it('should register click eventlistener to swipe view mode', () => { + const changeViewSpy = jest + .spyOn(ReplacedImageDiff.prototype, 'changeView') + .mockImplementation(() => {}); replacedImageDiff.bindEvents(); replacedImageDiff.viewModesEls[viewTypes.SWIPE].click(); + + expect(changeViewSpy).toHaveBeenCalledWith(viewTypes.SWIPE, expect.any(Object)); }); - it('should register click eventlistener to onion skin view mode', (done) => { - jest.spyOn(ReplacedImageDiff.prototype, 'changeView').mockImplementation((viewMode) => { - expect(viewMode).toEqual(viewTypes.SWIPE); - done(); - }); + it('should register click eventlistener to onion skin view mode', () => { + const changeViewSpy = jest + .spyOn(ReplacedImageDiff.prototype, 'changeView') + .mockImplementation(() => {}); replacedImageDiff.bindEvents(); replacedImageDiff.viewModesEls[viewTypes.SWIPE].click(); + expect(changeViewSpy).toHaveBeenCalledWith(viewTypes.SWIPE, expect.any(Object)); }); }); @@ -325,32 +327,34 @@ describe('ReplacedImageDiff', () => { setupImageFrameEls(); }); - it('should pass showCommentIndicator normalized indicator values', (done) => { + it('should pass showCommentIndicator normalized indicator values', () => { jest.spyOn(imageDiffHelper, 'showCommentIndicator').mockImplementation(() => {}); - jest + const resizeCoordinatesToImageElementSpy = jest .spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement') - .mockImplementation((imageEl, meta) => { - expect(meta.x).toEqual(indicator.x); - expect(meta.y).toEqual(indicator.y); - expect(meta.width).toEqual(indicator.image.width); - expect(meta.height).toEqual(indicator.image.height); - done(); - }); + .mockImplementation(() => {}); + replacedImageDiff.renderNewView(indicator); + + expect(resizeCoordinatesToImageElementSpy).toHaveBeenCalledWith(undefined, { + x: indicator.x, + y: indicator.y, + width: indicator.image.width, + height: indicator.image.height, + }); }); - it('should call showCommentIndicator', (done) => { + it('should call showCommentIndicator', () => { const normalized = { normalized: true, }; jest.spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement').mockReturnValue(normalized); - jest + const showCommentIndicatorSpy = jest .spyOn(imageDiffHelper, 'showCommentIndicator') - .mockImplementation((imageFrameEl, normalizedIndicator) => { - expect(normalizedIndicator).toEqual(normalized); - done(); - }); + .mockImplementation(() => {}); + replacedImageDiff.renderNewView(indicator); + + expect(showCommentIndicatorSpy).toHaveBeenCalledWith(undefined, normalized); }); }); }); diff --git a/spec/frontend/import_entities/components/import_status_spec.js b/spec/frontend/import_entities/components/import_status_spec.js new file mode 100644 index 00000000000..686a21e3923 --- /dev/null +++ b/spec/frontend/import_entities/components/import_status_spec.js @@ -0,0 +1,145 @@ +import { GlAccordionItem, GlBadge, GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import ImportStatus from '~/import_entities/components/import_status.vue'; +import { STATUSES } from '~/import_entities/constants'; + +describe('Import entities status component', () => { + let wrapper; + + const createComponent = (propsData) => { + wrapper = shallowMount(ImportStatus, { + propsData, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('success status', () => { + const getStatusText = () => wrapper.findComponent(GlBadge).text(); + + it('displays finished status as complete when no stats are provided', () => { + createComponent({ + status: STATUSES.FINISHED, + }); + expect(getStatusText()).toBe('Complete'); + }); + + it('displays finished status as complete when all stats items were processed', () => { + const statItems = { label: 100, note: 200 }; + + createComponent({ + status: STATUSES.FINISHED, + stats: { + fetched: { ...statItems }, + imported: { ...statItems }, + }, + }); + + expect(getStatusText()).toBe('Complete'); + }); + + it('displays finished status as partial when all stats items were processed', () => { + const statItems = { label: 100, note: 200 }; + + createComponent({ + status: STATUSES.FINISHED, + stats: { + fetched: { ...statItems }, + imported: { ...statItems, label: 50 }, + }, + }); + + expect(getStatusText()).toBe('Partial import'); + }); + }); + + describe('details drawer', () => { + const findDetailsDrawer = () => wrapper.findComponent(GlAccordionItem); + + it('renders details drawer to be present when stats are provided', () => { + createComponent({ + status: 'created', + stats: { fetched: { label: 1 }, imported: { label: 0 } }, + }); + + expect(findDetailsDrawer().exists()).toBe(true); + }); + + it('does not render details drawer when no stats are provided', () => { + createComponent({ + status: 'created', + }); + + expect(findDetailsDrawer().exists()).toBe(false); + }); + + it('does not render details drawer when stats are empty', () => { + createComponent({ + status: 'created', + stats: { fetched: {}, imported: {} }, + }); + + expect(findDetailsDrawer().exists()).toBe(false); + }); + + it('does not render details drawer when no known stats are provided', () => { + createComponent({ + status: 'created', + stats: { + fetched: { + UNKNOWN_STAT: 100, + }, + imported: { + UNKNOWN_STAT: 0, + }, + }, + }); + + expect(findDetailsDrawer().exists()).toBe(false); + }); + }); + + describe('stats display', () => { + const getStatusIcon = () => + wrapper.findComponent(GlAccordionItem).findComponent(GlIcon).props().name; + + const createComponentWithStats = ({ fetched, imported }) => { + createComponent({ + status: 'created', + stats: { + fetched: { label: fetched }, + imported: { label: imported }, + }, + }); + }; + + it('displays scheduled status when imported is 0', () => { + createComponentWithStats({ + fetched: 100, + imported: 0, + }); + + expect(getStatusIcon()).toBe('status-scheduled'); + }); + + it('displays running status when imported is not equal to fetched', () => { + createComponentWithStats({ + fetched: 100, + imported: 10, + }); + + expect(getStatusIcon()).toBe('status-running'); + }); + + it('displays success status when imported is equal to fetched', () => { + createComponentWithStats({ + fetched: 100, + imported: 100, + }); + + expect(getStatusIcon()).toBe('status-success'); + }); + }); +}); diff --git a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js index 16adf88700f..88fcedd31b2 100644 --- a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js +++ b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js @@ -31,7 +31,7 @@ describe('ImportProjectsTable', () => { const findImportAllButton = () => wrapper .findAll(GlButton) - .filter((w) => w.props().variant === 'success') + .filter((w) => w.props().variant === 'confirm') .at(0); const findImportAllModal = () => wrapper.find({ ref: 'importAllModal' }); diff --git a/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js b/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js index c8afa9ea57d..41a005199e1 100644 --- a/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js +++ b/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js @@ -98,6 +98,8 @@ describe('ProviderRepoTableRow', () => { }); describe('when rendering imported project', () => { + const FAKE_STATS = {}; + const repo = { importSource: { id: 'remote-1', @@ -109,6 +111,7 @@ describe('ProviderRepoTableRow', () => { fullPath: 'fullPath', importSource: 'importSource', importStatus: STATUSES.FINISHED, + stats: FAKE_STATS, }, }; @@ -134,6 +137,10 @@ describe('ProviderRepoTableRow', () => { it('does not render import button', () => { expect(findImportButton().exists()).toBe(false); }); + + it('passes stats to import status component', () => { + expect(wrapper.find(ImportStatus).props().stats).toBe(FAKE_STATS); + }); }); describe('when rendering incompatible project', () => { diff --git a/spec/frontend/import_entities/import_projects/store/mutations_spec.js b/spec/frontend/import_entities/import_projects/store/mutations_spec.js index e062d889325..77fae951300 100644 --- a/spec/frontend/import_entities/import_projects/store/mutations_spec.js +++ b/spec/frontend/import_entities/import_projects/store/mutations_spec.js @@ -232,6 +232,35 @@ describe('import_projects store mutations', () => { updatedProjects[0].importStatus, ); }); + + it('updates import stats of project', () => { + const repoId = 1; + state = { + repositories: [ + { importedProject: { id: repoId, stats: {} }, importStatus: STATUSES.STARTED }, + ], + }; + const newStats = { + fetched: { + label: 10, + }, + imported: { + label: 1, + }, + }; + + const updatedProjects = [ + { + id: repoId, + importStatus: STATUSES.FINISHED, + stats: newStats, + }, + ]; + + mutations[types.RECEIVE_JOBS_SUCCESS](state, updatedProjects); + + expect(state.repositories[0].importedProject.stats).toStrictEqual(newStats); + }); }); describe(`${types.REQUEST_NAMESPACES}`, () => { diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js index 9ed0294e876..a556f3c17f3 100644 --- a/spec/frontend/incidents/components/incidents_list_spec.js +++ b/spec/frontend/incidents/components/incidents_list_spec.js @@ -7,6 +7,7 @@ import { I18N, TH_CREATED_AT_TEST_ID, TH_SEVERITY_TEST_ID, + TH_ESCALATION_STATUS_TEST_ID, TH_PUBLISHED_TEST_ID, TH_INCIDENT_SLA_TEST_ID, trackIncidentCreateNewOptions, @@ -170,6 +171,7 @@ describe('Incidents List', () => { expect(link.text()).toBe(title); expect(link.attributes('href')).toContain(`issues/incident/${iid}`); + expect(link.find('.gl-text-truncate').exists()).toBe(true); }); describe('Assignees', () => { @@ -200,15 +202,14 @@ describe('Incidents List', () => { describe('Escalation status', () => { it('renders escalation status per row', () => { - expect(findEscalationStatus().length).toBe(mockIncidents.length); - - const actualStatuses = findEscalationStatus().wrappers.map((status) => status.text()); - expect(actualStatuses).toEqual([ - 'Triggered', - 'Acknowledged', - 'Resolved', - I18N.noEscalationStatus, - ]); + const statuses = findEscalationStatus().wrappers; + const expectedStatuses = ['Triggered', 'Acknowledged', 'Resolved', I18N.noEscalationStatus]; + + expect(statuses.length).toBe(mockIncidents.length); + statuses.forEach((status, index) => { + expect(status.text()).toEqual(expectedStatuses[index]); + expect(status.classes('gl-text-truncate')).toBe(true); + }); }); describe('when feature is disabled', () => { @@ -294,11 +295,12 @@ describe('Incidents List', () => { const noneSort = 'none'; it.each` - description | selector | initialSort | firstSort | nextSort - ${'creation date'} | ${TH_CREATED_AT_TEST_ID} | ${descSort} | ${ascSort} | ${descSort} - ${'severity'} | ${TH_SEVERITY_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} - ${'publish date'} | ${TH_PUBLISHED_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} - ${'due date'} | ${TH_INCIDENT_SLA_TEST_ID} | ${noneSort} | ${ascSort} | ${descSort} + description | selector | initialSort | firstSort | nextSort + ${'creation date'} | ${TH_CREATED_AT_TEST_ID} | ${descSort} | ${ascSort} | ${descSort} + ${'severity'} | ${TH_SEVERITY_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} + ${'status'} | ${TH_ESCALATION_STATUS_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} + ${'publish date'} | ${TH_PUBLISHED_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} + ${'due date'} | ${TH_INCIDENT_SLA_TEST_ID} | ${noneSort} | ${ascSort} | ${descSort} `( 'updates sort with new direction when sorting by $description', async ({ selector, initialSort, firstSort, nextSort }) => { diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index c4569070d09..ca481e009cf 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -9,8 +9,6 @@ import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue'; import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue'; import DynamicField from '~/integrations/edit/components/dynamic_field.vue'; import IntegrationForm from '~/integrations/edit/components/integration_form.vue'; -import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue'; -import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue'; import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue'; import ResetConfirmationModal from '~/integrations/edit/components/reset_confirmation_modal.vue'; import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; @@ -55,7 +53,6 @@ describe('IntegrationForm', () => { OverrideDropdown, ActiveCheckbox, ConfirmationModal, - JiraTriggerFields, TriggerFields, }, mocks: { @@ -74,8 +71,6 @@ describe('IntegrationForm', () => { const findProjectSaveButton = () => wrapper.findByTestId('save-button'); const findInstanceOrGroupSaveButton = () => wrapper.findByTestId('save-button-instance-group'); const findTestButton = () => wrapper.findByTestId('test-button'); - const findJiraTriggerFields = () => wrapper.findComponent(JiraTriggerFields); - const findJiraIssuesFields = () => wrapper.findComponent(JiraIssuesFields); const findTriggerFields = () => wrapper.findComponent(TriggerFields); const findGlForm = () => wrapper.findComponent(GlForm); const findRedirectToField = () => wrapper.findByTestId('redirect-to-field'); @@ -198,49 +193,6 @@ describe('IntegrationForm', () => { }); }); - describe('type is "slack"', () => { - beforeEach(() => { - createComponent({ - customStateProps: { type: 'slack' }, - }); - }); - - it('does not render JiraTriggerFields', () => { - expect(findJiraTriggerFields().exists()).toBe(false); - }); - - it('does not render JiraIssuesFields', () => { - expect(findJiraIssuesFields().exists()).toBe(false); - }); - }); - - describe('type is "jira"', () => { - beforeEach(() => { - jest.spyOn(document, 'querySelector').mockReturnValue(document.createElement('form')); - - createComponent({ - customStateProps: { type: 'jira', testPath: '/test' }, - mountFn: mountExtended, - }); - }); - - it('renders JiraTriggerFields', () => { - expect(findJiraTriggerFields().exists()).toBe(true); - }); - - it('renders JiraIssuesFields', () => { - expect(findJiraIssuesFields().exists()).toBe(true); - }); - - describe('when JiraIssueFields emits `request-jira-issue-types` event', () => { - it('dispatches `requestJiraIssueTypes` action', () => { - findJiraIssuesFields().vm.$emit('request-jira-issue-types'); - - expect(dispatch).toHaveBeenCalledWith('requestJiraIssueTypes', expect.any(FormData)); - }); - }); - }); - describe('triggerEvents is present', () => { it('renders TriggerFields', () => { const events = [{ title: 'push' }]; @@ -272,9 +224,6 @@ describe('IntegrationForm', () => { ]; createComponent({ - provide: { - glFeatures: { integrationFormSections: true }, - }, customStateProps: { sections: [mockSectionConnection], fields: [...sectionFields, ...nonSectionFields], @@ -363,9 +312,6 @@ describe('IntegrationForm', () => { describe('when integration has sections', () => { beforeEach(() => { createComponent({ - provide: { - glFeatures: { integrationFormSections: true }, - }, customStateProps: { sections: [mockSectionConnection], }, @@ -396,9 +342,6 @@ describe('IntegrationForm', () => { ]; createComponent({ - provide: { - glFeatures: { integrationFormSections: true }, - }, customStateProps: { sections: [mockSectionConnection], fields: [...sectionFields, ...nonSectionFields], @@ -417,9 +360,6 @@ describe('IntegrationForm', () => { ({ formActive, novalidate }) => { beforeEach(() => { createComponent({ - provide: { - glFeatures: { integrationFormSections: true }, - }, customStateProps: { sections: [mockSectionConnection], showActive: true, @@ -441,9 +381,6 @@ describe('IntegrationForm', () => { jest.spyOn(document, 'querySelector').mockReturnValue(document.createElement('form')); createComponent({ - provide: { - glFeatures: { integrationFormSections: true }, - }, customStateProps: { sections: [mockSectionConnection], testPath: '/test', diff --git a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js index 33fd08a5959..94e370a485f 100644 --- a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js +++ b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js @@ -10,7 +10,6 @@ describe('JiraIssuesFields', () => { let wrapper; const defaultProps = { - editProjectPath: '/edit', showJiraIssuesIntegration: true, showJiraVulnerabilitiesIntegration: true, upgradePlanPath: 'https://gitlab.com', @@ -46,7 +45,6 @@ describe('JiraIssuesFields', () => { const findPremiumUpgradeCTA = () => wrapper.findByTestId('premium-upgrade-cta'); const findUltimateUpgradeCTA = () => wrapper.findByTestId('ultimate-upgrade-cta'); const findJiraForVulnerabilities = () => wrapper.findByTestId('jira-for-vulnerabilities'); - const findConflictWarning = () => wrapper.findByTestId('conflict-warning-text'); const setEnableCheckbox = async (isEnabled = true) => findEnableCheckbox().vm.$emit('input', isEnabled); @@ -75,10 +73,9 @@ describe('JiraIssuesFields', () => { }); if (showJiraIssuesIntegration) { - it('renders checkbox and input field', () => { + it('renders enable checkbox', () => { expect(findEnableCheckbox().exists()).toBe(true); expect(findEnableCheckboxDisabled()).toBeUndefined(); - expect(findProjectKey().exists()).toBe(true); }); it('does not render the Premium CTA', () => { @@ -98,9 +95,8 @@ describe('JiraIssuesFields', () => { }); } } else { - it('does not render checkbox and input field', () => { + it('does not render enable checkbox', () => { expect(findEnableCheckbox().exists()).toBe(false); - expect(findProjectKey().exists()).toBe(false); }); it('renders the Premium CTA', () => { @@ -122,12 +118,8 @@ describe('JiraIssuesFields', () => { createComponent({ props: { initialProjectKey: '' } }); }); - it('renders disabled project_key input', () => { - const projectKey = findProjectKey(); - - expect(projectKey.exists()).toBe(true); - expect(projectKey.attributes('disabled')).toBe('disabled'); - expect(projectKey.attributes('required')).toBeUndefined(); + it('does not render project_key input', () => { + expect(findProjectKey().exists()).toBe(false); }); // As per https://vuejs.org/v2/guide/forms.html#Checkbox-1, @@ -137,45 +129,23 @@ describe('JiraIssuesFields', () => { }); describe('when isInheriting = true', () => { - it('disables checkbox and sets input as readonly', () => { + it('disables checkbox', () => { createComponent({ isInheriting: true }); expect(findEnableCheckboxDisabled()).toBe('disabled'); - expect(findProjectKey().attributes('readonly')).toBe('readonly'); }); }); describe('on enable issues', () => { - it('enables project_key input as required', async () => { + it('renders project_key input as required', async () => { await setEnableCheckbox(true); - expect(findProjectKey().attributes('disabled')).toBeUndefined(); + expect(findProjectKey().exists()).toBe(true); expect(findProjectKey().attributes('required')).toBe('required'); }); }); }); - it('contains link to editProjectPath', () => { - createComponent(); - - expect(wrapper.find(`a[href="${defaultProps.editProjectPath}"]`).exists()).toBe(true); - }); - - describe('GitLab issues warning', () => { - it.each` - gitlabIssuesEnabled | scenario - ${true} | ${'displays conflict warning'} - ${false} | ${'does not display conflict warning'} - `( - '$scenario when `gitlabIssuesEnabled` is `$gitlabIssuesEnabled`', - ({ gitlabIssuesEnabled }) => { - createComponent({ props: { gitlabIssuesEnabled } }); - - expect(findConflictWarning().exists()).toBe(gitlabIssuesEnabled); - }, - ); - }); - describe('Vulnerabilities creation', () => { beforeEach(() => { createComponent(); diff --git a/spec/frontend/invite_members/components/group_select_spec.js b/spec/frontend/invite_members/components/group_select_spec.js index 192f3fdd381..e1563a7bb3a 100644 --- a/spec/frontend/invite_members/components/group_select_spec.js +++ b/spec/frontend/invite_members/components/group_select_spec.js @@ -4,7 +4,6 @@ import waitForPromises from 'helpers/wait_for_promises'; import * as groupsApi from '~/api/groups_api'; import GroupSelect from '~/invite_members/components/group_select.vue'; -const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }; const group1 = { id: 1, full_name: 'Group One', avatar_url: 'test' }; const group2 = { id: 2, full_name: 'Group Two', avatar_url: 'test' }; const allGroups = [group1, group2]; @@ -13,7 +12,6 @@ const createComponent = (props = {}) => { return mount(GroupSelect, { propsData: { invalidGroups: [], - accessLevels, ...props, }, }); @@ -66,9 +64,8 @@ describe('GroupSelect', () => { resolveApiRequest({ data: allGroups }); expect(groupsApi.getGroups).toHaveBeenCalledWith(group1.name, { - active: true, exclude_internal: true, - min_access_level: accessLevels.Guest, + active: true, }); }); diff --git a/spec/frontend/invite_members/components/invite_groups_modal_spec.js b/spec/frontend/invite_members/components/invite_groups_modal_spec.js index 8085f48f6e2..f9cb4a149f2 100644 --- a/spec/frontend/invite_members/components/invite_groups_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_groups_modal_spec.js @@ -42,18 +42,19 @@ describe('InviteGroupsModal', () => { wrapper = null; }); + const findModal = () => wrapper.findComponent(GlModal); const findGroupSelect = () => wrapper.findComponent(GroupSelect); const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text(); - const findCancelButton = () => wrapper.findByTestId('cancel-button'); - const findInviteButton = () => wrapper.findByTestId('invite-button'); const findMembersFormGroup = () => wrapper.findByTestId('members-form-group'); const membersFormGroupInvalidFeedback = () => findMembersFormGroup().attributes('invalid-feedback'); - const clickInviteButton = () => findInviteButton().vm.$emit('click'); - const clickCancelButton = () => findCancelButton().vm.$emit('click'); - const triggerGroupSelect = (val) => findGroupSelect().vm.$emit('input', val); const findBase = () => wrapper.findComponent(InviteModalBase); - const hideModal = () => wrapper.findComponent(GlModal).vm.$emit('hide'); + const triggerGroupSelect = (val) => findGroupSelect().vm.$emit('input', val); + const emitEventFromModal = (eventName) => () => + findModal().vm.$emit(eventName, { preventDefault: jest.fn() }); + const hideModal = emitEventFromModal('hidden'); + const clickInviteButton = emitEventFromModal('primary'); + const clickCancelButton = emitEventFromModal('cancel'); describe('displaying the correct introText and form group description', () => { describe('when inviting to a project', () => { 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 dd16bb48cb8..84317da39e6 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -23,7 +23,7 @@ import ContentTransition from '~/vue_shared/components/content_transition.vue'; import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; import { getParameterValues } from '~/lib/utils/url_utility'; -import { apiPaths, membersApiResponse, invitationsApiResponse } from '../mock_data/api_responses'; +import { GROUPS_INVITATIONS_PATH, invitationsApiResponse } from '../mock_data/api_responses'; import { propsData, inviteSource, @@ -85,12 +85,13 @@ describe('InviteMembersModal', () => { mock.restore(); }); + const findModal = () => wrapper.findComponent(GlModal); const findBase = () => wrapper.findComponent(InviteModalBase); const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text(); - const findCancelButton = () => wrapper.findByTestId('cancel-button'); - const findInviteButton = () => wrapper.findByTestId('invite-button'); - const clickInviteButton = () => findInviteButton().vm.$emit('click'); - const clickCancelButton = () => findCancelButton().vm.$emit('click'); + const emitEventFromModal = (eventName) => () => + findModal().vm.$emit(eventName, { preventDefault: jest.fn() }); + const clickInviteButton = emitEventFromModal('primary'); + const clickCancelButton = emitEventFromModal('cancel'); const findMembersFormGroup = () => wrapper.findByTestId('members-form-group'); const membersFormGroupInvalidFeedback = () => findMembersFormGroup().attributes('invalid-feedback'); @@ -276,7 +277,7 @@ describe('InviteMembersModal', () => { }); it('renders the modal with the correct title', () => { - expect(wrapper.findComponent(GlModal).props('title')).toBe(MEMBERS_MODAL_CELEBRATE_TITLE); + expect(findModal().props('title')).toBe(MEMBERS_MODAL_CELEBRATE_TITLE); }); it('includes the correct celebration text and emoji', () => { @@ -300,11 +301,8 @@ describe('InviteMembersModal', () => { }); describe('submitting the invite form', () => { - const mockMembersApi = (code, data) => { - mock.onPost(apiPaths.GROUPS_MEMBERS).reply(code, data); - }; const mockInvitationsApi = (code, data) => { - mock.onPost(apiPaths.GROUPS_INVITATIONS).reply(code, data); + mock.onPost(GROUPS_INVITATIONS_PATH).reply(code, data); }; const expectedEmailRestrictedError = @@ -328,7 +326,7 @@ describe('InviteMembersModal', () => { await triggerMembersTokenSelect([user1, user2]); wrapper.vm.$toast = { show: jest.fn() }; - jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); + jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData }); }); describe('when triggered from regular mounting', () => { @@ -336,12 +334,8 @@ describe('InviteMembersModal', () => { clickInviteButton(); }); - it('sets isLoading on the Invite button when it is clicked', () => { - expect(findInviteButton().props('loading')).toBe(true); - }); - - it('calls Api addGroupMembersByUserId with the correct params', () => { - expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, postData); + it('calls Api inviteGroupMembers with the correct params', () => { + expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, postData); }); it('displays the successful toastMessage', () => { @@ -371,21 +365,9 @@ describe('InviteMembersModal', () => { await triggerMembersTokenSelect([user1]); }); - it('displays "Member already exists" api message for http status conflict', async () => { - mockMembersApi(httpStatus.CONFLICT, membersApiResponse.MEMBER_ALREADY_EXISTS); - - clickInviteButton(); - - await waitForPromises(); - - expect(membersFormGroupInvalidFeedback()).toBe('Member already exists'); - expect(findMembersSelect().props('validationState')).toBe(false); - expect(findInviteButton().props('loading')).toBe(false); - }); - describe('clearing the invalid state and message', () => { beforeEach(async () => { - mockMembersApi(httpStatus.CONFLICT, membersApiResponse.MEMBER_ALREADY_EXISTS); + mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN); clickInviteButton(); @@ -393,7 +375,9 @@ describe('InviteMembersModal', () => { }); it('clears the error when the list of members to invite is cleared', async () => { - expect(membersFormGroupInvalidFeedback()).toBe('Member already exists'); + expect(membersFormGroupInvalidFeedback()).toBe( + Object.values(invitationsApiResponse.EMAIL_TAKEN.message)[0], + ); expect(findMembersSelect().props('validationState')).toBe(false); findMembersSelect().vm.$emit('clear'); @@ -414,7 +398,7 @@ describe('InviteMembersModal', () => { }); it('clears the error when the modal is hidden', async () => { - wrapper.findComponent(GlModal).vm.$emit('hide'); + findModal().vm.$emit('hidden'); await nextTick(); @@ -424,15 +408,17 @@ describe('InviteMembersModal', () => { }); it('clears the invalid state and message once the list of members to invite is cleared', async () => { - mockMembersApi(httpStatus.CONFLICT, membersApiResponse.MEMBER_ALREADY_EXISTS); + mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN); clickInviteButton(); await waitForPromises(); - expect(membersFormGroupInvalidFeedback()).toBe('Member already exists'); + expect(membersFormGroupInvalidFeedback()).toBe( + Object.values(invitationsApiResponse.EMAIL_TAKEN.message)[0], + ); expect(findMembersSelect().props('validationState')).toBe(false); - expect(findInviteButton().props('loading')).toBe(false); + expect(findModal().props('actionPrimary').attributes.loading).toBe(false); findMembersSelect().vm.$emit('clear'); @@ -440,11 +426,14 @@ describe('InviteMembersModal', () => { expect(membersFormGroupInvalidFeedback()).toBe(''); expect(findMembersSelect().props('validationState')).toBe(null); - expect(findInviteButton().props('loading')).toBe(false); + expect(findModal().props('actionPrimary').attributes.loading).toBe(false); }); it('displays the generic error for http server error', async () => { - mockMembersApi(httpStatus.INTERNAL_SERVER_ERROR, 'Request failed with status code 500'); + mockInvitationsApi( + httpStatus.INTERNAL_SERVER_ERROR, + 'Request failed with status code 500', + ); clickInviteButton(); @@ -454,7 +443,7 @@ describe('InviteMembersModal', () => { }); it('displays the restricted user api message for response with bad request', async () => { - mockMembersApi(httpStatus.BAD_REQUEST, membersApiResponse.SINGLE_USER_RESTRICTED); + mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_RESTRICTED); clickInviteButton(); @@ -464,7 +453,7 @@ describe('InviteMembersModal', () => { }); it('displays the first part of the error when multiple existing users are restricted by email', async () => { - mockMembersApi(httpStatus.CREATED, membersApiResponse.MULTIPLE_USERS_RESTRICTED); + mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED); clickInviteButton(); @@ -475,19 +464,6 @@ describe('InviteMembersModal', () => { ); expect(findMembersSelect().props('validationState')).toBe(false); }); - - it('displays an access_level error message received for the existing user', async () => { - mockMembersApi(httpStatus.BAD_REQUEST, membersApiResponse.SINGLE_USER_ACCESS_LEVEL); - - clickInviteButton(); - - await waitForPromises(); - - expect(membersFormGroupInvalidFeedback()).toBe( - 'should be greater than or equal to Owner inherited membership from group Gitlab Org', - ); - expect(findMembersSelect().props('validationState')).toBe(false); - }); }); }); @@ -508,7 +484,7 @@ describe('InviteMembersModal', () => { await triggerMembersTokenSelect([user3]); wrapper.vm.$toast = { show: jest.fn() }; - jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData }); + jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData }); }); describe('when triggered from regular mounting', () => { @@ -516,8 +492,8 @@ describe('InviteMembersModal', () => { clickInviteButton(); }); - it('calls Api inviteGroupMembersByEmail with the correct params', () => { - expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, postData); + it('calls Api inviteGroupMembers with the correct params', () => { + expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, postData); }); it('displays the successful toastMessage', () => { @@ -542,7 +518,7 @@ describe('InviteMembersModal', () => { expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError); expect(findMembersSelect().props('validationState')).toBe(false); - expect(findInviteButton().props('loading')).toBe(false); + expect(findModal().props('actionPrimary').attributes.loading).toBe(false); }); it('displays the restricted email error when restricted email is invited', async () => { @@ -554,23 +530,11 @@ describe('InviteMembersModal', () => { expect(membersFormGroupInvalidFeedback()).toContain(expectedEmailRestrictedError); expect(findMembersSelect().props('validationState')).toBe(false); - expect(findInviteButton().props('loading')).toBe(false); - }); - - it('displays the successful toast message when email has already been invited', async () => { - mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN); - wrapper.vm.$toast = { show: jest.fn() }; - - clickInviteButton(); - - await waitForPromises(); - - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added'); - expect(findMembersSelect().props('validationState')).toBe(null); + expect(findModal().props('actionPrimary').attributes.loading).toBe(false); }); it('displays the first error message when multiple emails return a restricted error message', async () => { - mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_EMAIL_RESTRICTED); + mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED); clickInviteButton(); @@ -617,19 +581,17 @@ describe('InviteMembersModal', () => { format: 'json', tasks_to_be_done: [], tasks_project_id: '', + user_id: '1', + email: 'email@example.com', }; - const emailPostData = { ...postData, email: 'email@example.com' }; - const idPostData = { ...postData, user_id: '1' }; - describe('when invites are sent successfully', () => { beforeEach(async () => { createComponent(); await triggerMembersTokenSelect([user1, user3]); wrapper.vm.$toast = { show: jest.fn() }; - jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData }); - jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); + jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData }); }); describe('when triggered from regular mounting', () => { @@ -637,12 +599,8 @@ describe('InviteMembersModal', () => { clickInviteButton(); }); - it('calls Api inviteGroupMembersByEmail with the correct params', () => { - expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, emailPostData); - }); - - it('calls Api addGroupMembersByUserId with the correct params', () => { - expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, idPostData); + it('calls Api inviteGroupMembers with the correct params', () => { + expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, postData); }); it('displays the successful toastMessage', () => { @@ -655,12 +613,8 @@ describe('InviteMembersModal', () => { clickInviteButton(); - expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, { - ...emailPostData, - invite_source: '_invite_source_', - }); - expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, { - ...idPostData, + expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, { + ...postData, invite_source: '_invite_source_', }); }); @@ -673,7 +627,6 @@ describe('InviteMembersModal', () => { await triggerMembersTokenSelect([user1, user3]); mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID); - mockMembersApi(httpStatus.OK, '200 OK'); clickInviteButton(); }); @@ -692,7 +645,7 @@ describe('InviteMembersModal', () => { await triggerMembersTokenSelect([user3]); wrapper.vm.$toast = { show: jest.fn() }; - jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({}); + jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({}); }); it('tracks the view for learn_gitlab source', () => { 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 9e17112fb15..8355ae67f20 100644 --- a/spec/frontend/invite_members/components/invite_modal_base_spec.js +++ b/spec/frontend/invite_members/components/invite_modal_base_spec.js @@ -49,8 +49,6 @@ describe('InviteModalBase', () => { const findDatepicker = () => wrapper.findComponent(GlDatepicker); const findLink = () => wrapper.findComponent(GlLink); const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text(); - const findCancelButton = () => wrapper.findByTestId('cancel-button'); - const findInviteButton = () => wrapper.findByTestId('invite-button'); const findMembersFormGroup = () => wrapper.findByTestId('members-form-group'); describe('rendering the modal', () => { @@ -67,15 +65,21 @@ describe('InviteModalBase', () => { }); it('renders the Cancel button text correctly', () => { - expect(findCancelButton().text()).toBe(CANCEL_BUTTON_TEXT); - }); - - it('renders the Invite button text correctly', () => { - expect(findInviteButton().text()).toBe(INVITE_BUTTON_TEXT); + expect(wrapper.findComponent(GlModal).props('actionCancel')).toMatchObject({ + text: CANCEL_BUTTON_TEXT, + }); }); - it('renders the Invite button modal without isLoading', () => { - expect(findInviteButton().props('loading')).toBe(false); + it('renders the Invite button correctly', () => { + expect(wrapper.findComponent(GlModal).props('actionPrimary')).toMatchObject({ + text: INVITE_BUTTON_TEXT, + attributes: { + variant: 'confirm', + disabled: false, + loading: false, + 'data-qa-selector': 'invite_button', + }, + }); }); describe('rendering the access levels dropdown', () => { @@ -114,7 +118,7 @@ describe('InviteModalBase', () => { isLoading: true, }); - expect(findInviteButton().props('loading')).toBe(true); + expect(wrapper.findComponent(GlModal).props('actionPrimary').attributes.loading).toBe(true); }); it('with invalidFeedbackMessage, set members form group validation state', () => { 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 196a716d08c..bf5564e4d63 100644 --- a/spec/frontend/invite_members/components/members_token_select_spec.js +++ b/spec/frontend/invite_members/components/members_token_select_spec.js @@ -95,7 +95,7 @@ describe('MembersTokenSelect', () => { expect(UserApi.getUsers).toHaveBeenCalledWith(searchParam, { active: true, - exclude_internal: true, + without_project_bots: true, }); expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false); }); @@ -172,7 +172,7 @@ describe('MembersTokenSelect', () => { expect(UserApi.getUsers).toHaveBeenCalledWith(searchParam, { active: true, - exclude_internal: true, + without_project_bots: true, saml_provider_id: samlProviderId, }); }); diff --git a/spec/frontend/invite_members/components/user_limit_notification_spec.js b/spec/frontend/invite_members/components/user_limit_notification_spec.js new file mode 100644 index 00000000000..c779cf2ee3f --- /dev/null +++ b/spec/frontend/invite_members/components/user_limit_notification_spec.js @@ -0,0 +1,71 @@ +import { GlAlert, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import UserLimitNotification from '~/invite_members/components/user_limit_notification.vue'; + +describe('UserLimitNotification', () => { + let wrapper; + + const findAlert = () => wrapper.findComponent(GlAlert); + + const createComponent = (providers = {}) => { + wrapper = shallowMountExtended(UserLimitNotification, { + provide: { + name: 'my group', + newTrialRegistrationPath: 'newTrialRegistrationPath', + purchasePath: 'purchasePath', + freeUsersLimit: 5, + membersCount: 1, + ...providers, + }, + stubs: { GlSprintf }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when limit is not reached', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders empty block', () => { + expect(findAlert().exists()).toBe(false); + }); + }); + + describe('when close to limit', () => { + beforeEach(() => { + createComponent({ membersCount: 3 }); + }); + + it("renders user's limit notification", () => { + const alert = findAlert(); + + expect(alert.attributes('title')).toEqual( + 'You only have space for 2 more members in my group', + ); + + expect(alert.text()).toEqual( + 'To get more members an owner of this namespace can start a trial or upgrade to a paid tier.', + ); + }); + }); + + describe('when limit is reached', () => { + beforeEach(() => { + createComponent({ membersCount: 5 }); + }); + + it("renders user's limit notification", () => { + const alert = findAlert(); + + expect(alert.attributes('title')).toEqual("You've reached your 5 members limit for my group"); + + expect(alert.text()).toEqual( + 'New members will be unable to participate. You can manage your members by removing ones you no longer need. To get more members an owner of this namespace can start a trial or upgrade to a paid tier.', + ); + }); + }); +}); diff --git a/spec/frontend/invite_members/mock_data/api_responses.js b/spec/frontend/invite_members/mock_data/api_responses.js index a3e426376d8..4ad3b6aeb66 100644 --- a/spec/frontend/invite_members/mock_data/api_responses.js +++ b/spec/frontend/invite_members/mock_data/api_responses.js @@ -1,12 +1,12 @@ -const INVITATIONS_API_EMAIL_INVALID = { +const EMAIL_INVALID = { message: { error: 'email contains an invalid email address' }, }; -const INVITATIONS_API_ERROR_EMAIL_INVALID = { +const ERROR_EMAIL_INVALID = { error: 'email contains an invalid email address', }; -const INVITATIONS_API_EMAIL_RESTRICTED = { +const EMAIL_RESTRICTED = { message: { 'email@example.com': "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.", @@ -14,65 +14,31 @@ const INVITATIONS_API_EMAIL_RESTRICTED = { status: 'error', }; -const INVITATIONS_API_MULTIPLE_EMAIL_RESTRICTED = { +const MULTIPLE_RESTRICTED = { message: { 'email@example.com': "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.", 'email4@example.com': "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check the Domain denylist.", - }, - status: 'error', -}; - -const INVITATIONS_API_EMAIL_TAKEN = { - message: { - 'email@example.org': 'Invite email has already been taken', - }, - status: 'error', -}; - -const MEMBERS_API_MEMBER_ALREADY_EXISTS = { - message: 'Member already exists', -}; - -const MEMBERS_API_SINGLE_USER_RESTRICTED = { - message: { - user: [ + root: "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.", - ], }, + status: 'error', }; -const MEMBERS_API_SINGLE_USER_ACCESS_LEVEL = { +const EMAIL_TAKEN = { message: { - access_level: [ - 'should be greater than or equal to Owner inherited membership from group Gitlab Org', - ], + 'email@example.org': "The member's email address has already been taken", }, -}; - -const MEMBERS_API_MULTIPLE_USERS_RESTRICTED = { - message: - "root: 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. and user18: The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check the Domain denylist. and john_doe31: The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Email restrictions for sign-ups.", status: 'error', }; -export const apiPaths = { - GROUPS_MEMBERS: '/api/v4/groups/1/members', - GROUPS_INVITATIONS: '/api/v4/groups/1/invitations', -}; - -export const membersApiResponse = { - MEMBER_ALREADY_EXISTS: MEMBERS_API_MEMBER_ALREADY_EXISTS, - SINGLE_USER_ACCESS_LEVEL: MEMBERS_API_SINGLE_USER_ACCESS_LEVEL, - SINGLE_USER_RESTRICTED: MEMBERS_API_SINGLE_USER_RESTRICTED, - MULTIPLE_USERS_RESTRICTED: MEMBERS_API_MULTIPLE_USERS_RESTRICTED, -}; +export const GROUPS_INVITATIONS_PATH = '/api/v4/groups/1/invitations'; export const invitationsApiResponse = { - EMAIL_INVALID: INVITATIONS_API_EMAIL_INVALID, - ERROR_EMAIL_INVALID: INVITATIONS_API_ERROR_EMAIL_INVALID, - EMAIL_RESTRICTED: INVITATIONS_API_EMAIL_RESTRICTED, - MULTIPLE_EMAIL_RESTRICTED: INVITATIONS_API_MULTIPLE_EMAIL_RESTRICTED, - EMAIL_TAKEN: INVITATIONS_API_EMAIL_TAKEN, + EMAIL_INVALID, + ERROR_EMAIL_INVALID, + EMAIL_RESTRICTED, + MULTIPLE_RESTRICTED, + EMAIL_TAKEN, }; diff --git a/spec/frontend/invite_members/mock_data/group_modal.js b/spec/frontend/invite_members/mock_data/group_modal.js index c05c4edb7d0..c8588683885 100644 --- a/spec/frontend/invite_members/mock_data/group_modal.js +++ b/spec/frontend/invite_members/mock_data/group_modal.js @@ -1,5 +1,6 @@ export const propsData = { id: '1', + rootId: '1', name: 'test name', isProject: false, invalidGroups: [], diff --git a/spec/frontend/invite_members/mock_data/member_modal.js b/spec/frontend/invite_members/mock_data/member_modal.js index 590502909b2..1b0cc57fb5b 100644 --- a/spec/frontend/invite_members/mock_data/member_modal.js +++ b/spec/frontend/invite_members/mock_data/member_modal.js @@ -1,5 +1,6 @@ export const propsData = { id: '1', + rootId: '1', name: 'test name', isProject: false, accessLevels: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }, 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 e2cc87c8547..8b2064df374 100644 --- a/spec/frontend/invite_members/utils/response_message_parser_spec.js +++ b/spec/frontend/invite_members/utils/response_message_parser_spec.js @@ -2,23 +2,19 @@ import { responseMessageFromSuccess, responseMessageFromError, } from '~/invite_members/utils/response_message_parser'; -import { membersApiResponse, invitationsApiResponse } from '../mock_data/api_responses'; +import { invitationsApiResponse } from '../mock_data/api_responses'; describe('Response message parser', () => { const expectedMessage = 'expected display and message.'; describe('parse message from successful response', () => { const exampleKeyedMsg = { 'email@example.com': expectedMessage }; - const exampleFirstPartMultiple = 'username1: expected display and message.'; - const exampleUserMsgMultiple = - ' and username2: id not found and restricted email. and username3: email is restricted.'; it.each([ - [[{ data: { message: expectedMessage } }]], - [[{ data: { message: exampleFirstPartMultiple + exampleUserMsgMultiple } }]], - [[{ data: { error: expectedMessage } }]], - [[{ data: { message: [expectedMessage] } }]], - [[{ data: { message: exampleKeyedMsg } }]], + [{ data: { message: expectedMessage } }], + [{ data: { error: expectedMessage } }], + [{ data: { message: [expectedMessage] } }], + [{ data: { message: exampleKeyedMsg } }], ])(`returns "${expectedMessage}" from success response: %j`, (successResponse) => { expect(responseMessageFromSuccess(successResponse)).toBe(expectedMessage); }); @@ -27,8 +23,6 @@ describe('Response message parser', () => { describe('message from error response', () => { it.each([ [{ response: { data: { error: expectedMessage } } }], - [{ response: { data: { message: { user: [expectedMessage] } } } }], - [{ response: { data: { message: { access_level: [expectedMessage] } } } }], [{ response: { data: { message: { error: expectedMessage } } } }], [{ response: { data: { message: expectedMessage } } }], ])(`returns "${expectedMessage}" from error response: %j`, (errorResponse) => { @@ -41,18 +35,10 @@ describe('Response message parser', () => { "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."; it.each([ - [[{ data: membersApiResponse.MULTIPLE_USERS_RESTRICTED }]], - [[{ data: invitationsApiResponse.MULTIPLE_EMAIL_RESTRICTED }]], - [[{ data: invitationsApiResponse.EMAIL_RESTRICTED }]], + [{ data: invitationsApiResponse.MULTIPLE_RESTRICTED }], + [{ data: invitationsApiResponse.EMAIL_RESTRICTED }], ])(`returns "${expectedMessage}" from success response: %j`, (restrictedResponse) => { expect(responseMessageFromSuccess(restrictedResponse)).toBe(expected); }); - - it.each([[{ response: { data: membersApiResponse.SINGLE_USER_RESTRICTED } }]])( - `returns "${expectedMessage}" from error response: %j`, - (singleRestrictedResponse) => { - expect(responseMessageFromError(singleRestrictedResponse)).toBe(expected); - }, - ); }); }); diff --git a/spec/frontend/issuable/issuable_form_spec.js b/spec/frontend/issuable/issuable_form_spec.js index 321c61ead1e..99ed18cf5bd 100644 --- a/spec/frontend/issuable/issuable_form_spec.js +++ b/spec/frontend/issuable/issuable_form_spec.js @@ -1,20 +1,46 @@ import $ from 'jquery'; import IssuableForm from '~/issuable/issuable_form'; - -function createIssuable() { - const instance = new IssuableForm($(document.createElement('form'))); - - instance.titleField = $(document.createElement('input')); - - return instance; -} +import setWindowLocation from 'helpers/set_window_location_helper'; describe('IssuableForm', () => { let instance; + const createIssuable = (form) => { + instance = new IssuableForm(form); + }; + beforeEach(() => { - instance = createIssuable(); + setFixtures(` + <form> + <input name="[title]" /> + </form> + `); + createIssuable($('form')); + }); + + describe('initAutosave', () => { + it('creates autosave with the searchTerm included', () => { + setWindowLocation('https://gitlab.test/foo?bar=true'); + const autosave = instance.initAutosave(); + + expect(autosave.key.includes('bar=true')).toBe(true); + }); + + it("creates autosave fields without the searchTerm if it's an issue new form", () => { + setFixtures(` + <form data-new-issue-path="/issues/new"> + <input name="[title]" /> + </form> + `); + createIssuable($('form')); + + setWindowLocation('https://gitlab.test/issues/new?bar=true'); + + const autosave = instance.initAutosave(); + + expect(autosave.key.includes('bar=true')).toBe(false); + }); }); describe('removeWip', () => { diff --git a/spec/frontend/issues/create_merge_request_dropdown_spec.js b/spec/frontend/issues/create_merge_request_dropdown_spec.js index c2cfb16fdf7..20b26f5abba 100644 --- a/spec/frontend/issues/create_merge_request_dropdown_spec.js +++ b/spec/frontend/issues/create_merge_request_dropdown_spec.js @@ -15,7 +15,7 @@ describe('CreateMergeRequestDropdown', () => { <div id="dummy-wrapper-element"> <div class="available"></div> <div class="unavailable"> - <div class="gl-spinner"></div> + <div class="js-create-mr-spinner"></div> <div class="text"></div> </div> <div class="js-ref"></div> @@ -38,21 +38,16 @@ describe('CreateMergeRequestDropdown', () => { }); describe('getRef', () => { - it('escapes branch names correctly', (done) => { + it('escapes branch names correctly', async () => { const endpoint = `${dropdown.refsPath}contains%23hash`; jest.spyOn(axios, 'get'); axiosMock.onGet(endpoint).replyOnce({}); - dropdown - .getRef('contains#hash') - .then(() => { - expect(axios.get).toHaveBeenCalledWith( - endpoint, - expect.objectContaining({ cancelToken: expect.anything() }), - ); - }) - .then(done) - .catch(done.fail); + await dropdown.getRef('contains#hash'); + expect(axios.get).toHaveBeenCalledWith( + endpoint, + expect.objectContaining({ cancelToken: expect.anything() }), + ); }); }); diff --git a/spec/frontend/issues/list/components/issue_card_time_info_spec.js b/spec/frontend/issues/list/components/issue_card_time_info_spec.js index e9c48b60da4..c3f13ca6f9a 100644 --- a/spec/frontend/issues/list/components/issue_card_time_info_spec.js +++ b/spec/frontend/issues/list/components/issue_card_time_info_spec.js @@ -1,10 +1,11 @@ import { GlIcon, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { useFakeDate } from 'helpers/fake_date'; +import { IssuableStatus } from '~/issues/constants'; import IssueCardTimeInfo from '~/issues/list/components/issue_card_time_info.vue'; describe('CE IssueCardTimeInfo component', () => { - useFakeDate(2020, 11, 11); + useFakeDate(2020, 11, 11); // 2020 Dec 11 let wrapper; @@ -24,7 +25,7 @@ describe('CE IssueCardTimeInfo component', () => { const findDueDate = () => wrapper.find('[data-testid="issuable-due-date"]'); const mountComponent = ({ - closedAt = null, + state = IssuableStatus.Open, dueDate = issue.dueDate, milestoneDueDate = issue.milestone.dueDate, milestoneStartDate = issue.milestone.startDate, @@ -38,7 +39,7 @@ describe('CE IssueCardTimeInfo component', () => { dueDate: milestoneDueDate, startDate: milestoneStartDate, }, - closedAt, + state, dueDate, }, }, @@ -91,7 +92,7 @@ describe('CE IssueCardTimeInfo component', () => { describe('when in the past', () => { describe('when issue is open', () => { it('renders in red', () => { - wrapper = mountComponent({ dueDate: new Date('2020-10-10') }); + wrapper = mountComponent({ dueDate: '2020-10-10' }); expect(findDueDate().classes()).toContain('gl-text-red-500'); }); @@ -100,8 +101,8 @@ describe('CE IssueCardTimeInfo component', () => { describe('when issue is closed', () => { it('does not render in red', () => { wrapper = mountComponent({ - dueDate: new Date('2020-10-10'), - closedAt: '2020-09-05T13:06:25Z', + dueDate: '2020-10-10', + state: IssuableStatus.Closed, }); expect(findDueDate().classes()).not.toContain('gl-text-red-500'); 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 33c7ccac180..5a9bd1ff8e4 100644 --- a/spec/frontend/issues/list/components/issues_list_app_spec.js +++ b/spec/frontend/issues/list/components/issues_list_app_spec.js @@ -452,13 +452,26 @@ describe('CE IssuesListApp component', () => { }); describe('IssuableByEmail component', () => { - describe.each([true, false])(`when issue creation by email is enabled=%s`, (enabled) => { - it(`${enabled ? 'renders' : 'does not render'}`, () => { - wrapper = mountComponent({ provide: { initialEmail: enabled } }); - - expect(findIssuableByEmail().exists()).toBe(enabled); - }); - }); + describe.each` + initialEmail | hasAnyIssues | isSignedIn | exists + ${false} | ${false} | ${false} | ${false} + ${false} | ${true} | ${false} | ${false} + ${false} | ${false} | ${true} | ${false} + ${false} | ${true} | ${true} | ${false} + ${true} | ${false} | ${false} | ${false} + ${true} | ${true} | ${false} | ${false} + ${true} | ${false} | ${true} | ${true} + ${true} | ${true} | ${true} | ${true} + `( + `when issue creation by email is enabled=$initialEmail`, + ({ initialEmail, hasAnyIssues, isSignedIn, exists }) => { + it(`${initialEmail ? 'renders' : 'does not render'}`, () => { + wrapper = mountComponent({ provide: { initialEmail, hasAnyIssues, isSignedIn } }); + + expect(findIssuableByEmail().exists()).toBe(exists); + }); + }, + ); }); describe('empty states', () => { diff --git a/spec/frontend/issues/list/mock_data.js b/spec/frontend/issues/list/mock_data.js index c883b20682e..b1a135ceb18 100644 --- a/spec/frontend/issues/list/mock_data.js +++ b/spec/frontend/issues/list/mock_data.js @@ -21,7 +21,6 @@ export const getIssuesQueryResponse = { __typename: 'Issue', id: 'gid://gitlab/Issue/123456', iid: '789', - closedAt: null, confidential: false, createdAt: '2021-05-22T04:08:01Z', downvotes: 2, @@ -30,6 +29,7 @@ export const getIssuesQueryResponse = { humanTimeEstimate: null, mergeRequestsCount: false, moved: false, + state: 'opened', title: 'Issue title', updatedAt: '2021-05-22T04:08:01Z', upvotes: 3, diff --git a/spec/frontend/issues/related_merge_requests/store/actions_spec.js b/spec/frontend/issues/related_merge_requests/store/actions_spec.js index 5f232fee09b..4327fac15d4 100644 --- a/spec/frontend/issues/related_merge_requests/store/actions_spec.js +++ b/spec/frontend/issues/related_merge_requests/store/actions_spec.js @@ -23,90 +23,82 @@ describe('RelatedMergeRequest store actions', () => { }); describe('setInitialState', () => { - it('commits types.SET_INITIAL_STATE with given props', (done) => { + it('commits types.SET_INITIAL_STATE with given props', () => { const props = { a: 1, b: 2 }; - testAction( + return testAction( actions.setInitialState, props, {}, [{ type: types.SET_INITIAL_STATE, payload: props }], [], - done, ); }); }); describe('requestData', () => { - it('commits types.REQUEST_DATA', (done) => { - testAction(actions.requestData, null, {}, [{ type: types.REQUEST_DATA }], [], done); + it('commits types.REQUEST_DATA', () => { + return testAction(actions.requestData, null, {}, [{ type: types.REQUEST_DATA }], []); }); }); describe('receiveDataSuccess', () => { - it('commits types.RECEIVE_DATA_SUCCESS with data', (done) => { + it('commits types.RECEIVE_DATA_SUCCESS with data', () => { const data = { a: 1, b: 2 }; - testAction( + return testAction( actions.receiveDataSuccess, data, {}, [{ type: types.RECEIVE_DATA_SUCCESS, payload: data }], [], - done, ); }); }); describe('receiveDataError', () => { - it('commits types.RECEIVE_DATA_ERROR', (done) => { - testAction( + it('commits types.RECEIVE_DATA_ERROR', () => { + return testAction( actions.receiveDataError, null, {}, [{ type: types.RECEIVE_DATA_ERROR }], [], - done, ); }); }); describe('fetchMergeRequests', () => { describe('for a successful request', () => { - it('should dispatch success action', (done) => { + it('should dispatch success action', () => { const data = { a: 1 }; mock.onGet(`${state.apiEndpoint}?per_page=100`).replyOnce(200, data, { 'x-total': 2 }); - testAction( + return testAction( actions.fetchMergeRequests, null, state, [], [{ type: 'requestData' }, { type: 'receiveDataSuccess', payload: { data, total: 2 } }], - done, ); }); }); describe('for a failing request', () => { - it('should dispatch error action', (done) => { + it('should dispatch error action', async () => { mock.onGet(`${state.apiEndpoint}?per_page=100`).replyOnce(400); - testAction( + await testAction( actions.fetchMergeRequests, null, state, [], [{ type: 'requestData' }, { type: 'receiveDataError' }], - () => { - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ - message: expect.stringMatching('Something went wrong'), - }); - - done(); - }, ); + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringMatching('Something went wrong'), + }); }); }); }); diff --git a/spec/frontend/issues/show/components/app_spec.js b/spec/frontend/issues/show/components/app_spec.js index ac2717a5028..5ab64d8e9ca 100644 --- a/spec/frontend/issues/show/components/app_spec.js +++ b/spec/frontend/issues/show/components/app_spec.js @@ -2,11 +2,14 @@ import { GlIntersectionObserver } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import '~/behaviors/markdown/render_gfm'; import { IssuableStatus, IssuableStatusText } from '~/issues/constants'; import IssuableApp from '~/issues/show/components/app.vue'; import DescriptionComponent from '~/issues/show/components/description.vue'; +import EditedComponent from '~/issues/show/components/edited.vue'; +import FormComponent from '~/issues/show/components/form.vue'; +import TitleComponent from '~/issues/show/components/title.vue'; import IncidentTabs from '~/issues/show/components/incidents/incident_tabs.vue'; import PinnedLinks from '~/issues/show/components/pinned_links.vue'; import { POLLING_DELAY } from '~/issues/show/constants'; @@ -21,10 +24,6 @@ import { zoomMeetingUrl, } from '../mock_data/mock_data'; -function formatText(text) { - return text.trim().replace(/\s\s+/g, ' '); -} - jest.mock('~/lib/utils/url_utility'); jest.mock('~/issues/show/event_hub'); @@ -39,10 +38,15 @@ describe('Issuable output', () => { const findLockedBadge = () => wrapper.findByTestId('locked'); const findConfidentialBadge = () => wrapper.findByTestId('confidential'); const findHiddenBadge = () => wrapper.findByTestId('hidden'); - const findAlert = () => wrapper.find('.alert'); + + const findTitle = () => wrapper.findComponent(TitleComponent); + const findDescription = () => wrapper.findComponent(DescriptionComponent); + const findEdited = () => wrapper.findComponent(EditedComponent); + const findForm = () => wrapper.findComponent(FormComponent); + const findPinnedLinks = () => wrapper.findComponent(PinnedLinks); const mountComponent = (props = {}, options = {}, data = {}) => { - wrapper = mountExtended(IssuableApp, { + wrapper = shallowMountExtended(IssuableApp, { directives: { GlTooltip: createMockDirective(), }, @@ -104,23 +108,15 @@ describe('Issuable output', () => { }); it('should render a title/description/edited and update title/description/edited on update', () => { - let editedText; return axios .waitForAll() .then(() => { - editedText = wrapper.find('.edited-text'); - }) - .then(() => { - expect(document.querySelector('title').innerText).toContain('this is a title (#1)'); - expect(wrapper.find('.title').text()).toContain('this is a title'); - expect(wrapper.find('.md').text()).toContain('this is a description!'); - expect(wrapper.find('.js-task-list-field').element.value).toContain( - 'this is a description', - ); + expect(findTitle().props('titleText')).toContain('this is a title'); + expect(findDescription().props('descriptionText')).toContain('this is a description'); - expect(formatText(editedText.text())).toMatch(/Edited[\s\S]+?by Some User/); - expect(editedText.find('.author-link').attributes('href')).toMatch(/\/some_user$/); - expect(editedText.find('time').text()).toBeTruthy(); + expect(findEdited().exists()).toBe(true); + expect(findEdited().props('updatedByPath')).toMatch(/\/some_user$/); + expect(findEdited().props('updatedAt')).toBeTruthy(); expect(wrapper.vm.state.lock_version).toBe(initialRequest.lock_version); }) .then(() => { @@ -128,20 +124,13 @@ describe('Issuable output', () => { return axios.waitForAll(); }) .then(() => { - expect(document.querySelector('title').innerText).toContain('2 (#1)'); - expect(wrapper.find('.title').text()).toContain('2'); - expect(wrapper.find('.md').text()).toContain('42'); - expect(wrapper.find('.js-task-list-field').element.value).toContain('42'); - expect(wrapper.find('.edited-text').text()).toBeTruthy(); - expect(formatText(wrapper.find('.edited-text').text())).toMatch( - /Edited[\s\S]+?by Other User/, - ); + expect(findTitle().props('titleText')).toContain('2'); + expect(findDescription().props('descriptionText')).toContain('42'); - expect(editedText.find('.author-link').attributes('href')).toMatch(/\/other_user$/); - expect(editedText.find('time').text()).toBeTruthy(); - // As the lock_version value does not differ from the server, - // we should not see an alert - expect(findAlert().exists()).toBe(false); + expect(findEdited().exists()).toBe(true); + expect(findEdited().props('updatedByName')).toBe('Other User'); + expect(findEdited().props('updatedByPath')).toMatch(/\/other_user$/); + expect(findEdited().props('updatedAt')).toBeTruthy(); }); }); @@ -149,7 +138,7 @@ describe('Issuable output', () => { wrapper.vm.showForm = true; await nextTick(); - expect(wrapper.find('.markdown-selector').exists()).toBe(true); + expect(findForm().exists()).toBe(true); }); it('does not show actions if permissions are incorrect', async () => { @@ -157,7 +146,7 @@ describe('Issuable output', () => { wrapper.setProps({ canUpdate: false }); await nextTick(); - expect(wrapper.find('.markdown-selector').exists()).toBe(false); + expect(findForm().exists()).toBe(false); }); it('does not update formState if form is already open', async () => { @@ -177,8 +166,7 @@ describe('Issuable output', () => { ${'zoomMeetingUrl'} | ${zoomMeetingUrl} ${'publishedIncidentUrl'} | ${publishedIncidentUrl} `('sets the $prop correctly on underlying pinned links', ({ prop, value }) => { - expect(wrapper.vm[prop]).toBe(value); - expect(wrapper.find(`[data-testid="${prop}"]`).attributes('href')).toBe(value); + expect(findPinnedLinks().props(prop)).toBe(value); }); }); @@ -327,7 +315,6 @@ describe('Issuable output', () => { expect(wrapper.vm.formState.lockedWarningVisible).toBe(true); expect(wrapper.vm.formState.lock_version).toBe(1); - expect(findAlert().exists()).toBe(true); }); }); @@ -374,15 +361,22 @@ describe('Issuable output', () => { }); describe('show inline edit button', () => { - it('should not render by default', () => { - expect(wrapper.find('.btn-edit').exists()).toBe(true); + it('should render by default', () => { + expect(findTitle().props('showInlineEditButton')).toBe(true); }); it('should render if showInlineEditButton', async () => { wrapper.setProps({ showInlineEditButton: true }); await nextTick(); - expect(wrapper.find('.btn-edit').exists()).toBe(true); + expect(findTitle().props('showInlineEditButton')).toBe(true); + }); + + it('should not render if showInlineEditButton is false', async () => { + wrapper.setProps({ showInlineEditButton: false }); + + await nextTick(); + expect(findTitle().props('showInlineEditButton')).toBe(false); }); }); @@ -533,13 +527,11 @@ describe('Issuable output', () => { describe('Composable description component', () => { const findIncidentTabs = () => wrapper.findComponent(IncidentTabs); - const findDescriptionComponent = () => wrapper.findComponent(DescriptionComponent); - const findPinnedLinks = () => wrapper.findComponent(PinnedLinks); const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6'; describe('when using description component', () => { it('renders the description component', () => { - expect(findDescriptionComponent().exists()).toBe(true); + expect(findDescription().exists()).toBe(true); }); it('does not render incident tabs', () => { @@ -572,8 +564,8 @@ describe('Issuable output', () => { ); }); - it('renders the description component', () => { - expect(findDescriptionComponent().exists()).toBe(true); + it('does not the description component', () => { + expect(findDescription().exists()).toBe(false); }); it('renders incident tabs', () => { diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js index 08f8996de6f..0b3daadae1d 100644 --- a/spec/frontend/issues/show/components/description_spec.js +++ b/spec/frontend/issues/show/components/description_spec.js @@ -1,26 +1,35 @@ import $ from 'jquery'; import { nextTick } from 'vue'; import '~/behaviors/markdown/render_gfm'; -import { GlPopover, GlModal } from '@gitlab/ui'; +import { GlTooltip, GlModal } from '@gitlab/ui'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { stubComponent } from 'helpers/stub_component'; import { TEST_HOST } from 'helpers/test_constants'; import { mockTracking } from 'helpers/tracking_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import createFlash from '~/flash'; import Description from '~/issues/show/components/description.vue'; +import { updateHistory } from '~/lib/utils/url_utility'; import TaskList from '~/task_list'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; import { descriptionProps as initialProps, descriptionHtmlWithCheckboxes, + descriptionHtmlWithTask, } from '../mock_data/mock_data'; jest.mock('~/flash'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + updateHistory: jest.fn(), +})); jest.mock('~/task_list'); const showModal = jest.fn(); const hideModal = jest.fn(); +const $toast = { + show: jest.fn(), +}; describe('Description component', () => { let wrapper; @@ -28,10 +37,9 @@ describe('Description component', () => { const findGfmContent = () => wrapper.find('[data-testid="gfm-content"]'); const findTextarea = () => wrapper.find('[data-testid="textarea"]'); const findTaskActionButtons = () => wrapper.findAll('.js-add-task'); - const findConvertToTaskButton = () => wrapper.find('[data-testid="convert-to-task"]'); - const findTaskSvg = () => wrapper.find('[data-testid="issue-open-m-icon"]'); + const findConvertToTaskButton = () => wrapper.find('.js-add-task'); - const findPopovers = () => wrapper.findAllComponents(GlPopover); + const findTooltips = () => wrapper.findAllComponents(GlTooltip); const findModal = () => wrapper.findComponent(GlModal); const findCreateWorkItem = () => wrapper.findComponent(CreateWorkItem); const findWorkItemDetailModal = () => wrapper.findComponent(WorkItemDetailModal); @@ -39,10 +47,14 @@ describe('Description component', () => { function createComponent({ props = {}, provide = {} } = {}) { wrapper = shallowMountExtended(Description, { propsData: { + issueId: 1, ...initialProps, ...props, }, provide, + mocks: { + $toast, + }, stubs: { GlModal: stubComponent(GlModal, { methods: { @@ -50,12 +62,13 @@ describe('Description component', () => { hide: hideModal, }, }), - GlPopover, }, }); } beforeEach(() => { + setWindowLocation(TEST_HOST); + if (!document.querySelector('.issuable-meta')) { const metaData = document.createElement('div'); metaData.classList.add('issuable-meta'); @@ -253,9 +266,9 @@ describe('Description component', () => { expect(findTaskActionButtons()).toHaveLength(3); }); - it('renders a list of popovers corresponding to checkboxes in description HTML', () => { - expect(findPopovers()).toHaveLength(3); - expect(findPopovers().at(0).props('target')).toBe( + it('renders a list of tooltips corresponding to checkboxes in description HTML', () => { + expect(findTooltips()).toHaveLength(3); + expect(findTooltips().at(0).props('target')).toBe( findTaskActionButtons().at(0).attributes('id'), ); }); @@ -264,92 +277,113 @@ describe('Description component', () => { expect(findModal().props('visible')).toBe(false); }); - it('opens a modal when a button on popover is clicked and displays correct title', async () => { - findConvertToTaskButton().vm.$emit('click'); - expect(showModal).toHaveBeenCalled(); - await nextTick(); + 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('closes the modal on `closeCreateTaskModal` event', () => { - findConvertToTaskButton().vm.$emit('click'); + it('closes the modal on `closeCreateTaskModal` event', async () => { + await findConvertToTaskButton().trigger('click'); findCreateWorkItem().vm.$emit('closeModal'); expect(hideModal).toHaveBeenCalled(); }); - it('updates description HTML on `onCreate` event', async () => { - const newTitle = 'New title'; - findConvertToTaskButton().vm.$emit('click'); - findCreateWorkItem().vm.$emit('onCreate', { title: newTitle }); + it('emits `updateDescription` on `onCreate` event', () => { + const newDescription = `<p>New description</p>`; + findCreateWorkItem().vm.$emit('onCreate', newDescription); expect(hideModal).toHaveBeenCalled(); - await nextTick(); + expect(wrapper.emitted('updateDescription')).toEqual([[newDescription]]); + }); + + it('shows toast after delete success', async () => { + findWorkItemDetailModal().vm.$emit('workItemDeleted'); - expect(findTaskSvg().exists()).toBe(true); - expect(wrapper.text()).toContain(newTitle); + expect($toast.show).toHaveBeenCalledWith('Work item deleted'); }); }); describe('work items detail', () => { - const id = '1'; - const title = 'my first task'; - const type = 'task'; + const findTaskLink = () => wrapper.find('a.gfm-issue'); - const createThenClickOnTask = () => { - findConvertToTaskButton().vm.$emit('click'); - findCreateWorkItem().vm.$emit('onCreate', { id, title, type }); - return wrapper.findByRole('button', { name: title }).trigger('click'); - }; - - beforeEach(() => { - createComponent({ - props: { - descriptionHtml: descriptionHtmlWithCheckboxes, - }, - provide: { - glFeatures: { workItems: true }, - }, + describe('when opening and closing', () => { + beforeEach(() => { + createComponent({ + props: { + descriptionHtml: descriptionHtmlWithTask, + }, + provide: { + glFeatures: { workItems: true }, + }, + }); + return nextTick(); }); - return nextTick(); - }); - it('opens when task button is clicked', async () => { - expect(findWorkItemDetailModal().props('visible')).toBe(false); + it('opens when task button is clicked', async () => { + expect(findWorkItemDetailModal().props('visible')).toBe(false); - await createThenClickOnTask(); + await findTaskLink().trigger('click'); - expect(findWorkItemDetailModal().props('visible')).toBe(true); - }); + expect(findWorkItemDetailModal().props('visible')).toBe(true); + expect(updateHistory).toHaveBeenCalledWith({ + url: `${TEST_HOST}/?work_item_id=2`, + replace: true, + }); + }); - it('closes from an open state', async () => { - await createThenClickOnTask(); + it('closes from an open state', async () => { + await findTaskLink().trigger('click'); - expect(findWorkItemDetailModal().props('visible')).toBe(true); + expect(findWorkItemDetailModal().props('visible')).toBe(true); - findWorkItemDetailModal().vm.$emit('close'); - await nextTick(); + findWorkItemDetailModal().vm.$emit('close'); + await nextTick(); - expect(findWorkItemDetailModal().props('visible')).toBe(false); - }); + expect(findWorkItemDetailModal().props('visible')).toBe(false); + expect(updateHistory).toHaveBeenLastCalledWith({ + url: `${TEST_HOST}/`, + replace: true, + }); + }); - it('shows error on error', async () => { - const message = 'I am error'; + it('tracks when opened', async () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - await createThenClickOnTask(); - findWorkItemDetailModal().vm.$emit('error', message); + await findTaskLink().trigger('click'); - expect(createFlash).toHaveBeenCalledWith({ message }); + expect(trackingSpy).toHaveBeenCalledWith( + 'workItems:show', + 'viewed_work_item_from_modal', + { + category: 'workItems:show', + label: 'work_item_view', + property: 'type_task', + }, + ); + }); }); - it('tracks when opened', async () => { - const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - - await createThenClickOnTask(); - - expect(trackingSpy).toHaveBeenCalledWith('workItems:show', 'viewed_work_item_from_modal', { - category: 'workItems:show', - label: 'work_item_view', - property: 'type_task', - }); + describe('when url query `work_item_id` exists', () => { + it.each` + behavior | workItemId | visible + ${'opens'} | ${'123'} | ${true} + ${'does not open'} | ${'123e'} | ${false} + ${'does not open'} | ${'12e3'} | ${false} + ${'does not open'} | ${'1e23'} | ${false} + ${'does not open'} | ${'x'} | ${false} + ${'does not open'} | ${'undefined'} | ${false} + `( + '$behavior when url contains `work_item_id=$workItemId`', + async ({ workItemId, visible }) => { + setWindowLocation(`?work_item_id=${workItemId}`); + + createComponent({ + props: { descriptionHtml: descriptionHtmlWithTask }, + provide: { glFeatures: { workItems: true } }, + }); + + expect(findWorkItemDetailModal().props('visible')).toBe(visible); + }, + ); }); }); }); diff --git a/spec/frontend/issues/show/components/fields/description_spec.js b/spec/frontend/issues/show/components/fields/description_spec.js index dd511c3945c..0dcd70ac19b 100644 --- a/spec/frontend/issues/show/components/fields/description_spec.js +++ b/spec/frontend/issues/show/components/fields/description_spec.js @@ -14,9 +14,8 @@ describe('Description field component', () => { propsData: { markdownPreviewPath: '/', markdownDocsPath: '/', - formState: { - description, - }, + quickActionsDocsPath: '/', + value: description, }, stubs: { MarkdownField, diff --git a/spec/frontend/issues/show/components/fields/description_template_spec.js b/spec/frontend/issues/show/components/fields/description_template_spec.js index abe2805e5b2..79a3bfa9840 100644 --- a/spec/frontend/issues/show/components/fields/description_template_spec.js +++ b/spec/frontend/issues/show/components/fields/description_template_spec.js @@ -1,74 +1,65 @@ -import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; import descriptionTemplate from '~/issues/show/components/fields/description_template.vue'; describe('Issue description template component with templates as hash', () => { - let vm; - let formState; + let wrapper; + const defaultOptions = { + propsData: { + value: 'test', + issuableTemplates: { + test: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }], + }, + projectId: 1, + projectPath: '/', + namespacePath: '/', + projectNamespace: '/', + }, + }; - beforeEach(() => { - const Component = Vue.extend(descriptionTemplate); - formState = { - description: 'test', - }; + const findIssuableSelector = () => wrapper.find('.js-issuable-selector'); - vm = new Component({ - propsData: { - formState, - issuableTemplates: { - test: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }], - }, - projectId: 1, - projectPath: '/', - namespacePath: '/', - projectNamespace: '/', - }, - }).$mount(); + const createComponent = (options = defaultOptions) => { + wrapper = shallowMount(descriptionTemplate, options); + }; + + afterEach(() => { + wrapper.destroy(); }); it('renders templates as JSON hash in data attribute', () => { - expect(vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data')).toBe( + createComponent(); + expect(findIssuableSelector().attributes('data-data')).toBe( '{"test":[{"name":"test","id":"test","project_path":"/","namespace_path":"/"}]}', ); }); - it('updates formState when changing template', () => { - vm.issuableTemplate.editor.setValue('test new template'); + it('emits input event', () => { + createComponent(); + wrapper.vm.issuableTemplate.editor.setValue('test new template'); - expect(formState.description).toBe('test new template'); + expect(wrapper.emitted('input')).toEqual([['test new template']]); }); - it('returns formState description with editor getValue', () => { - formState.description = 'testing new template'; - - expect(vm.issuableTemplate.editor.getValue()).toBe('testing new template'); + it('returns value with editor getValue', () => { + createComponent(); + expect(wrapper.vm.issuableTemplate.editor.getValue()).toBe('test'); }); -}); - -describe('Issue description template component with templates as array', () => { - let vm; - let formState; - beforeEach(() => { - const Component = Vue.extend(descriptionTemplate); - formState = { - description: 'test', - }; - - vm = new Component({ - propsData: { - formState, - issuableTemplates: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }], - projectId: 1, - projectPath: '/', - namespacePath: '/', - projectNamespace: '/', - }, - }).$mount(); - }); - - it('renders templates as JSON array in data attribute', () => { - expect(vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data')).toBe( - '[{"name":"test","id":"test","project_path":"/","namespace_path":"/"}]', - ); + describe('Issue description template component with templates as array', () => { + it('renders templates as JSON array in data attribute', () => { + createComponent({ + propsData: { + value: 'test', + issuableTemplates: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }], + projectId: 1, + projectPath: '/', + namespacePath: '/', + projectNamespace: '/', + }, + }); + expect(findIssuableSelector().attributes('data-data')).toBe( + '[{"name":"test","id":"test","project_path":"/","namespace_path":"/"}]', + ); + }); }); }); diff --git a/spec/frontend/issues/show/components/fields/title_spec.js b/spec/frontend/issues/show/components/fields/title_spec.js index efd0b6fbd30..de04405d89b 100644 --- a/spec/frontend/issues/show/components/fields/title_spec.js +++ b/spec/frontend/issues/show/components/fields/title_spec.js @@ -12,9 +12,7 @@ describe('Title field component', () => { wrapper = shallowMount(TitleField, { propsData: { - formState: { - title: 'test', - }, + value: 'test', }, }); }); diff --git a/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js index 20c6cda33d4..35acca60de7 100644 --- a/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js +++ b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js @@ -34,8 +34,9 @@ describe('Incident Tabs component', () => { provide: { fullPath: '', iid: '', + projectId: '', uploadMetricsFeatureAvailable: true, - glFeatures: { incidentTimelineEventTab: true, incidentTimelineEvents: true }, + glFeatures: { incidentTimeline: true, incidentTimelineEvents: true }, }, data() { return { alert: mockAlert, ...data }; @@ -57,7 +58,6 @@ describe('Incident Tabs component', () => { const findTabs = () => wrapper.findAll(GlTab); const findSummaryTab = () => findTabs().at(0); - const findMetricsTab = () => wrapper.find('[data-testid="metrics-tab"]'); const findAlertDetailsTab = () => wrapper.find('[data-testid="alert-details-tab"]'); const findAlertDetailsComponent = () => wrapper.find(AlertDetailsTable); const findDescriptionComponent = () => wrapper.find(DescriptionComponent); @@ -111,20 +111,6 @@ describe('Incident Tabs component', () => { }); }); - describe('upload metrics feature available', () => { - it('shows the metric tab when metrics are available', () => { - mountComponent({}, { provide: { uploadMetricsFeatureAvailable: true } }); - - expect(findMetricsTab().exists()).toBe(true); - }); - - it('hides the tab when metrics are not available', () => { - mountComponent({}, { provide: { uploadMetricsFeatureAvailable: false } }); - - expect(findMetricsTab().exists()).toBe(false); - }); - }); - describe('Snowplow tracking', () => { beforeEach(() => { jest.spyOn(Tracking, 'event'); diff --git a/spec/frontend/issues/show/mock_data/mock_data.js b/spec/frontend/issues/show/mock_data/mock_data.js index 89653ff82b2..7b0b8ca686a 100644 --- a/spec/frontend/issues/show/mock_data/mock_data.js +++ b/spec/frontend/issues/show/mock_data/mock_data.js @@ -72,3 +72,18 @@ export const descriptionHtmlWithCheckboxes = ` </li> </ul> `; + +export const descriptionHtmlWithTask = ` + <ul data-sourcepos="1:1-3:7" class="task-list" dir="auto"> + <li data-sourcepos="1:1-1:10" class="task-list-item"> + <input type="checkbox" class="task-list-item-checkbox" disabled> + <a href="/gitlab-org/gitlab-test/-/issues/48" data-original="#48+" data-link="false" data-link-reference="false" data-project="1" data-issue="2" data-reference-format="+" data-reference-type="task" data-container="body" data-placement="top" title="1" class="gfm gfm-issue has-tooltip">1 (#48)</a> + </li> + <li data-sourcepos="2:1-2:7" class="task-list-item"> + <input type="checkbox" class="task-list-item-checkbox" disabled> 2 + </li> + <li data-sourcepos="3:1-3:7" class="task-list-item"> + <input type="checkbox" class="task-list-item-checkbox" disabled> 3 + </li> + </ul> +`; diff --git a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js index b0d5859cd31..3d7bf7acb41 100644 --- a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js @@ -72,7 +72,7 @@ describe('GroupsListItem', () => { expect(addSubscriptionSpy).toHaveBeenCalledWith(mockSubscriptionPath, mockGroup1.full_path); expect(persistAlert).toHaveBeenCalledWith({ - linkUrl: '/help/integration/jira_development_panel.html#usage', + linkUrl: '/help/integration/jira_development_panel.html#use-the-integration', message: 'You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}', title: 'Namespace successfully linked', diff --git a/spec/frontend/jira_connect/subscriptions/components/app_spec.js b/spec/frontend/jira_connect/subscriptions/components/app_spec.js index 6b3ca7ffd65..ce02144f22f 100644 --- a/spec/frontend/jira_connect/subscriptions/components/app_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/app_spec.js @@ -6,10 +6,12 @@ import JiraConnectApp from '~/jira_connect/subscriptions/components/app.vue'; import SignInPage from '~/jira_connect/subscriptions/pages/sign_in.vue'; import SubscriptionsPage from '~/jira_connect/subscriptions/pages/subscriptions.vue'; import UserLink from '~/jira_connect/subscriptions/components/user_link.vue'; +import BrowserSupportAlert from '~/jira_connect/subscriptions/components/browser_support_alert.vue'; import createStore from '~/jira_connect/subscriptions/store'; import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types'; import { I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE } from '~/jira_connect/subscriptions/constants'; import { __ } from '~/locale'; +import AccessorUtilities from '~/lib/utils/accessor'; import { mockSubscription } from '../mock_data'; jest.mock('~/jira_connect/subscriptions/utils', () => ({ @@ -26,6 +28,7 @@ describe('JiraConnectApp', () => { const findSignInPage = () => wrapper.findComponent(SignInPage); const findSubscriptionsPage = () => wrapper.findComponent(SubscriptionsPage); const findUserLink = () => wrapper.findComponent(UserLink); + const findBrowserSupportAlert = () => wrapper.findComponent(BrowserSupportAlert); const createComponent = ({ provide, mountFn = shallowMountExtended } = {}) => { store = createStore(); @@ -207,4 +210,29 @@ describe('JiraConnectApp', () => { }); }); }); + + describe.each` + jiraConnectOauthEnabled | canUseCrypto | shouldShowAlert + ${false} | ${false} | ${false} + ${false} | ${true} | ${false} + ${true} | ${false} | ${true} + ${true} | ${true} | ${false} + `( + 'when `jiraConnectOauth` feature flag is $jiraConnectOauthEnabled and `AccessorUtilities.canUseCrypto` returns $canUseCrypto', + ({ jiraConnectOauthEnabled, canUseCrypto, shouldShowAlert }) => { + beforeEach(() => { + jest.spyOn(AccessorUtilities, 'canUseCrypto').mockReturnValue(canUseCrypto); + + createComponent({ provide: { glFeatures: { jiraConnectOauth: jiraConnectOauthEnabled } } }); + }); + + it(`does ${shouldShowAlert ? '' : 'not'} render BrowserSupportAlert component`, () => { + expect(findBrowserSupportAlert().exists()).toBe(shouldShowAlert); + }); + + it(`does ${!shouldShowAlert ? '' : 'not'} render the main Jira Connect app template`, () => { + expect(wrapper.findByTestId('jira-connect-app').exists()).toBe(!shouldShowAlert); + }); + }, + ); }); diff --git a/spec/frontend/jira_connect/subscriptions/components/browser_support_alert_spec.js b/spec/frontend/jira_connect/subscriptions/components/browser_support_alert_spec.js new file mode 100644 index 00000000000..aa93a6be3c8 --- /dev/null +++ b/spec/frontend/jira_connect/subscriptions/components/browser_support_alert_spec.js @@ -0,0 +1,37 @@ +import { GlAlert, GlLink } from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; +import BrowserSupportAlert from '~/jira_connect/subscriptions/components/browser_support_alert.vue'; + +describe('BrowserSupportAlert', () => { + let wrapper; + + const createComponent = ({ mountFn = shallowMount } = {}) => { + wrapper = mountFn(BrowserSupportAlert); + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findLink = () => wrapper.findComponent(GlLink); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays a non-dismissible alert', () => { + createComponent(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().props()).toMatchObject({ + dismissible: false, + title: BrowserSupportAlert.i18n.title, + variant: 'danger', + }); + }); + + it('renders help link with target="_blank" and rel="noopener noreferrer"', () => { + createComponent({ mountFn: mount }); + expect(findLink().attributes()).toMatchObject({ + target: '_blank', + rel: 'noopener', + }); + }); +}); diff --git a/spec/frontend/jira_connect/subscriptions/components/compatibility_alert_spec.js b/spec/frontend/jira_connect/subscriptions/components/compatibility_alert_spec.js index f8ee8c2c664..5f38a0acb9d 100644 --- a/spec/frontend/jira_connect/subscriptions/components/compatibility_alert_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/compatibility_alert_spec.js @@ -29,7 +29,7 @@ describe('CompatibilityAlert', () => { createComponent({ mountFn: mount }); expect(findLink().attributes()).toMatchObject({ target: '_blank', - rel: 'noopener noreferrer', + rel: 'noopener', }); }); diff --git a/spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js b/spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js index 175896c4ab0..97d1b077164 100644 --- a/spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js +++ b/spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js @@ -5,7 +5,7 @@ import SignInLegacyButton from '~/jira_connect/subscriptions/components/sign_in_ import SignInOauthButton from '~/jira_connect/subscriptions/components/sign_in_oauth_button.vue'; import SubscriptionsList from '~/jira_connect/subscriptions/components/subscriptions_list.vue'; import createStore from '~/jira_connect/subscriptions/store'; -import { I18N_DEFAULT_SIGN_IN_BUTTON_TEXT } from '../../../../../app/assets/javascripts/jira_connect/subscriptions/constants'; +import { I18N_DEFAULT_SIGN_IN_BUTTON_TEXT } from '~/jira_connect/subscriptions/constants'; jest.mock('~/jira_connect/subscriptions/utils'); diff --git a/spec/frontend/jira_import/components/jira_import_form_spec.js b/spec/frontend/jira_import/components/jira_import_form_spec.js index 7a550d85204..41d3cd46d01 100644 --- a/spec/frontend/jira_import/components/jira_import_form_spec.js +++ b/spec/frontend/jira_import/components/jira_import_form_spec.js @@ -6,7 +6,7 @@ import { GlFormSelect, GlLabel, GlSearchBoxByType, - GlTable, + GlTableLite, } from '@gitlab/ui'; import { getByRole } from '@testing-library/dom'; import { mount, shallowMount } from '@vue/test-utils'; @@ -34,19 +34,19 @@ describe('JiraImportForm', () => { const currentUsername = 'mrgitlab'; - const getAlert = () => wrapper.find(GlAlert); + const getAlert = () => wrapper.findComponent(GlAlert); - const getSelectDropdown = () => wrapper.find(GlFormSelect); + const getSelectDropdown = () => wrapper.findComponent(GlFormSelect); - const getContinueButton = () => wrapper.find(GlButton); + const getContinueButton = () => wrapper.findComponent(GlButton); - const getCancelButton = () => wrapper.findAll(GlButton).at(1); + const getCancelButton = () => wrapper.findAllComponents(GlButton).at(1); - const getLabel = () => wrapper.find(GlLabel); + const getLabel = () => wrapper.findComponent(GlLabel); - const getTable = () => wrapper.find(GlTable); + const getTable = () => wrapper.findComponent(GlTableLite); - const getUserDropdown = () => getTable().find(GlDropdown); + const getUserDropdown = () => getTable().findComponent(GlDropdown); const getHeader = (name) => getByRole(wrapper.element, 'columnheader', { name }); @@ -107,14 +107,13 @@ describe('JiraImportForm', () => { mutateSpy.mockRestore(); querySpy.mockRestore(); wrapper.destroy(); - wrapper = null; }); describe('select dropdown project selection', () => { it('is shown', () => { wrapper = mountComponent(); - expect(wrapper.find(GlFormSelect).exists()).toBe(true); + expect(getSelectDropdown().exists()).toBe(true); }); it('contains a list of Jira projects to select from', () => { @@ -273,7 +272,7 @@ describe('JiraImportForm', () => { wrapper = mountComponent({ mountFunction: mount }); - wrapper.find(GlSearchBoxByType).vm.$emit('input', 'fred'); + wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'fred'); }); it('makes a GraphQL call', () => { diff --git a/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js b/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js new file mode 100644 index 00000000000..322cfa3ba1f --- /dev/null +++ b/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js @@ -0,0 +1,49 @@ +import { GlFilteredSearch } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import JobsFilteredSearch from '~/jobs/components/filtered_search/jobs_filtered_search.vue'; +import { mockFailedSearchToken } from '../../mock_data'; + +describe('Jobs filtered search', () => { + let wrapper; + + const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); + const getSearchToken = (type) => + findFilteredSearch() + .props('availableTokens') + .find((token) => token.type === type); + + const findStatusToken = () => getSearchToken('status'); + + const createComponent = () => { + wrapper = shallowMount(JobsFilteredSearch); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays filtered search', () => { + expect(findFilteredSearch().exists()).toBe(true); + }); + + it('displays status token', () => { + expect(findStatusToken()).toMatchObject({ + type: 'status', + icon: 'status', + title: 'Status', + unique: true, + operators: OPERATOR_IS_ONLY, + }); + }); + + it('emits filter token to parent component', () => { + findFilteredSearch().vm.$emit('submit', mockFailedSearchToken); + + expect(wrapper.emitted('filterJobsBySearch')).toEqual([[mockFailedSearchToken]]); + }); +}); diff --git a/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js b/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js new file mode 100644 index 00000000000..ce8e482cc16 --- /dev/null +++ b/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js @@ -0,0 +1,57 @@ +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import JobStatusToken from '~/jobs/components/filtered_search/tokens/job_status_token.vue'; + +describe('Job Status Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => + wrapper.findAllComponents(GlFilteredSearchSuggestion); + const findAllGlIcons = () => wrapper.findAllComponents(GlIcon); + + const defaultProps = { + config: { + type: 'status', + icon: 'status', + title: 'Status', + unique: true, + }, + value: { + data: '', + }, + }; + + const createComponent = () => { + wrapper = shallowMount(JobStatusToken, { + propsData: { + ...defaultProps, + }, + stubs: { + GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { + template: `<div><slot name="suggestions"></slot></div>`, + }), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('passes config correctly', () => { + expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); + }); + + it('renders all job statuses available', () => { + const expectedLength = 11; + + expect(findAllFilteredSearchSuggestions()).toHaveLength(expectedLength); + expect(findAllGlIcons()).toHaveLength(expectedLength); + }); +}); diff --git a/spec/frontend/jobs/components/job_app_spec.js b/spec/frontend/jobs/components/job_app_spec.js index 06ebcd7f134..9abe66b4696 100644 --- a/spec/frontend/jobs/components/job_app_spec.js +++ b/spec/frontend/jobs/components/job_app_spec.js @@ -375,8 +375,8 @@ describe('Job App', () => { }); describe('sidebar', () => { - it('has no blank blocks', (done) => { - setupAndMount({ + it('has no blank blocks', async () => { + await setupAndMount({ jobData: { duration: null, finished_at: null, @@ -387,17 +387,14 @@ describe('Job App', () => { tags: [], cancel_path: null, }, - }) - .then(() => { - const blocks = wrapper.findAll('.blocks-container > *').wrappers; - expect(blocks.length).toBeGreaterThan(0); - - blocks.forEach((block) => { - expect(block.text().trim()).not.toBe(''); - }); - }) - .then(done) - .catch(done.fail); + }); + + const blocks = wrapper.findAll('.blocks-container > *').wrappers; + expect(blocks.length).toBeGreaterThan(0); + + blocks.forEach((block) => { + expect(block.text().trim()).not.toBe(''); + }); }); }); }); diff --git a/spec/frontend/jobs/components/table/graphql/cache_config_spec.js b/spec/frontend/jobs/components/table/graphql/cache_config_spec.js index ac79186cb46..88c97285b85 100644 --- a/spec/frontend/jobs/components/table/graphql/cache_config_spec.js +++ b/spec/frontend/jobs/components/table/graphql/cache_config_spec.js @@ -33,6 +33,26 @@ describe('jobs/components/table/graphql/cache_config', () => { ); }); + it('should not add to existing cache if the incoming elements are the same', () => { + // simulate that this is the last page + const finalExistingCache = { + ...CIJobConnectionExistingCache, + pageInfo: { + hasNextPage: false, + }, + }; + + const res = cacheConfig.typePolicies.CiJobConnection.merge( + CIJobConnectionExistingCache, + finalExistingCache, + { + args: firstLoadArgs, + }, + ); + + expect(res.nodes).toHaveLength(CIJobConnectionExistingCache.nodes.length); + }); + it('should contain the pageInfo key as part of the result', () => { const res = cacheConfig.typePolicies.CiJobConnection.merge({}, CIJobConnectionIncomingCache, { args: firstLoadArgs, 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 4d51624dfff..986fba21fb9 100644 --- a/spec/frontend/jobs/components/table/job_table_app_spec.js +++ b/spec/frontend/jobs/components/table/job_table_app_spec.js @@ -1,30 +1,48 @@ -import { GlSkeletonLoader, GlAlert, GlEmptyState, GlIntersectionObserver } from '@gitlab/ui'; +import { + GlSkeletonLoader, + GlAlert, + GlEmptyState, + GlIntersectionObserver, + GlLoadingIcon, +} from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { s__ } from '~/locale'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; import getJobsQuery from '~/jobs/components/table/graphql/queries/get_jobs.query.graphql'; import JobsTable from '~/jobs/components/table/jobs_table.vue'; import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue'; import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue'; -import { mockJobsQueryResponse, mockJobsQueryEmptyResponse } from '../../mock_data'; +import JobsFilteredSearch from '~/jobs/components/filtered_search/jobs_filtered_search.vue'; +import { + mockJobsQueryResponse, + mockJobsQueryEmptyResponse, + mockFailedSearchToken, +} from '../../mock_data'; const projectPath = 'gitlab-org/gitlab'; Vue.use(VueApollo); +jest.mock('~/flash'); + describe('Job table app', () => { let wrapper; + let jobsTableVueSearch = true; const successHandler = jest.fn().mockResolvedValue(mockJobsQueryResponse); const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); const emptyHandler = jest.fn().mockResolvedValue(mockJobsQueryEmptyResponse); const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon); const findTable = () => wrapper.findComponent(JobsTable); const findTabs = () => wrapper.findComponent(JobsTableTabs); const findAlert = () => wrapper.findComponent(GlAlert); const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findFilteredSearch = () => wrapper.findComponent(JobsFilteredSearch); const triggerInfiniteScroll = () => wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear'); @@ -48,6 +66,7 @@ describe('Job table app', () => { }, provide: { fullPath: projectPath, + glFeatures: { jobsTableVueSearch }, }, apolloProvider: createMockApolloProvider(handler), }); @@ -58,11 +77,21 @@ describe('Job table app', () => { }); describe('loading state', () => { - it('should display skeleton loader when loading', () => { + beforeEach(() => { 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', () => { + findTabs().vm.$emit('fetchJobsByStatus', null); + + expect(findSkeletonLoader().exists()).toBe(true); + expect(findLoadingSpinner().exists()).toBe(false); }); }); @@ -76,6 +105,7 @@ describe('Job table app', () => { it('should display the jobs table with data', () => { expect(findTable().exists()).toBe(true); expect(findSkeletonLoader().exists()).toBe(false); + expect(findLoadingSpinner().exists()).toBe(false); }); it('should refetch jobs query on fetchJobsByStatus event', async () => { @@ -98,8 +128,12 @@ describe('Job table app', () => { }); it('handles infinite scrolling by calling fetch more', async () => { + expect(findLoadingSpinner().exists()).toBe(true); + await waitForPromises(); + expect(findLoadingSpinner().exists()).toBe(false); + expect(successHandler).toHaveBeenCalledWith({ after: 'eyJpZCI6IjIzMTcifQ', fullPath: 'gitlab-org/gitlab', @@ -137,4 +171,69 @@ describe('Job table app', () => { expect(findTable().exists()).toBe(true); }); }); + + describe('filtered search', () => { + it('should display filtered search', () => { + createComponent(); + + expect(findFilteredSearch().exists()).toBe(true); + }); + + // this test should be updated once BE supports tab and filtered search filtering + // https://gitlab.com/gitlab-org/gitlab/-/issues/356210 + it.each` + scope | shouldDisplay + ${null} | ${true} + ${['FAILED', 'SUCCESS', 'CANCELED']} | ${false} + `( + 'with tab scope $scope the filtered search displays $shouldDisplay', + async ({ scope, shouldDisplay }) => { + createComponent(); + + await waitForPromises(); + + await findTabs().vm.$emit('fetchJobsByStatus', scope); + + expect(findFilteredSearch().exists()).toBe(shouldDisplay); + }, + ); + + it('refetches jobs query when filtering', async () => { + createComponent(); + + jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn()); + + expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0); + + await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); + + expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(1); + }); + + it('shows raw text warning when user inputs raw text', async () => { + const expectedWarning = { + message: s__( + 'Jobs|Raw text search is not currently supported for the jobs filtered search feature. Please use the available search tokens.', + ), + type: 'warning', + }; + + createComponent(); + + jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn()); + + await findFilteredSearch().vm.$emit('filterJobsBySearch', ['raw text']); + + 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_tabs_spec.js b/spec/frontend/jobs/components/table/jobs_table_tabs_spec.js index ac9b45be932..23632001060 100644 --- a/spec/frontend/jobs/components/table/jobs_table_tabs_spec.js +++ b/spec/frontend/jobs/components/table/jobs_table_tabs_spec.js @@ -1,3 +1,4 @@ +import { GlTab } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { trimText } from 'helpers/text_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -7,16 +8,31 @@ describe('Jobs Table Tabs', () => { let wrapper; const defaultProps = { - jobCounts: { all: 848, pending: 0, running: 0, finished: 704 }, + allJobsCount: 286, + loading: false, }; - const findTab = (testId) => wrapper.findByTestId(testId); + const statuses = { + success: 'SUCCESS', + failed: 'FAILED', + canceled: 'CANCELED', + }; + + const findAllTab = () => wrapper.findByTestId('jobs-all-tab'); + const findFinishedTab = () => wrapper.findByTestId('jobs-finished-tab'); + + const triggerTabChange = (index) => wrapper.findAllComponents(GlTab).at(index).vm.$emit('click'); - const createComponent = () => { + const createComponent = (props = defaultProps) => { wrapper = extendedWrapper( mount(JobsTableTabs, { provide: { - ...defaultProps, + jobStatuses: { + ...statuses, + }, + }, + propsData: { + ...props, }, }), ); @@ -30,13 +46,21 @@ describe('Jobs Table Tabs', () => { wrapper.destroy(); }); + it('displays All tab with count', () => { + expect(trimText(findAllTab().text())).toBe(`All ${defaultProps.allJobsCount}`); + }); + + it('displays Finished tab with no count', () => { + expect(findFinishedTab().text()).toBe('Finished'); + }); + it.each` - tabId | text | count - ${'jobs-all-tab'} | ${'All'} | ${defaultProps.jobCounts.all} - ${'jobs-pending-tab'} | ${'Pending'} | ${defaultProps.jobCounts.pending} - ${'jobs-running-tab'} | ${'Running'} | ${defaultProps.jobCounts.running} - ${'jobs-finished-tab'} | ${'Finished'} | ${defaultProps.jobCounts.finished} - `('displays the right tab text and badge count', ({ tabId, text, count }) => { - expect(trimText(findTab(tabId).text())).toBe(`${text} ${count}`); + tabIndex | expectedScope + ${0} | ${null} + ${1} | ${[statuses.success, statuses.failed, statuses.canceled]} + `('emits fetchJobsByStatus with $expectedScope on tab change', ({ tabIndex, expectedScope }) => { + triggerTabChange(tabIndex); + + expect(wrapper.emitted()).toEqual({ fetchJobsByStatus: [[expectedScope]] }); }); }); diff --git a/spec/frontend/jobs/components/trigger_block_spec.js b/spec/frontend/jobs/components/trigger_block_spec.js index e0eb873dc2f..78596612d23 100644 --- a/spec/frontend/jobs/components/trigger_block_spec.js +++ b/spec/frontend/jobs/components/trigger_block_spec.js @@ -1,12 +1,12 @@ -import { GlButton, GlTable } from '@gitlab/ui'; +import { GlButton, GlTableLite } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import TriggerBlock from '~/jobs/components/trigger_block.vue'; describe('Trigger block', () => { let wrapper; - const findRevealButton = () => wrapper.find(GlButton); - const findVariableTable = () => wrapper.find(GlTable); + const findRevealButton = () => wrapper.findComponent(GlButton); + const findVariableTable = () => wrapper.findComponent(GlTableLite); const findShortToken = () => wrapper.find('[data-testid="trigger-short-token"]'); const findVariableValue = (index) => wrapper.findAll('[data-testid="trigger-build-value"]').at(index); @@ -22,7 +22,6 @@ describe('Trigger block', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('with short token and no variables', () => { diff --git a/spec/frontend/jobs/mock_data.js b/spec/frontend/jobs/mock_data.js index 73b9df1853d..27b6c04eded 100644 --- a/spec/frontend/jobs/mock_data.js +++ b/spec/frontend/jobs/mock_data.js @@ -1481,6 +1481,7 @@ export const mockJobsQueryResponse = { project: { id: '1', jobs: { + count: 1, pageInfo: { endCursor: 'eyJpZCI6IjIzMTcifQ', hasNextPage: true, @@ -1911,10 +1912,19 @@ export const CIJobConnectionIncomingCacheRunningStatus = { }; export const CIJobConnectionExistingCache = { + pageInfo: { + __typename: 'PageInfo', + endCursor: 'eyJpZCI6IjIwNTEifQ', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'eyJpZCI6IjIxNzMifQ', + }, nodes: [ - { __ref: 'CiJob:gid://gitlab/Ci::Build/2057' }, - { __ref: 'CiJob:gid://gitlab/Ci::Build/2056' }, - { __ref: 'CiJob:gid://gitlab/Ci::Build/2051' }, + { __ref: 'CiJob:gid://gitlab/Ci::Build/2100' }, + { __ref: 'CiJob:gid://gitlab/Ci::Build/2101' }, + { __ref: 'CiJob:gid://gitlab/Ci::Build/2102' }, ], statuses: 'PENDING', }; + +export const mockFailedSearchToken = { type: 'status', value: { data: 'FAILED', operator: '=' } }; diff --git a/spec/frontend/jobs/store/actions_spec.js b/spec/frontend/jobs/store/actions_spec.js index 16448d6a3ca..b9f97a3c3ae 100644 --- a/spec/frontend/jobs/store/actions_spec.js +++ b/spec/frontend/jobs/store/actions_spec.js @@ -39,62 +39,60 @@ describe('Job State actions', () => { }); describe('setJobEndpoint', () => { - it('should commit SET_JOB_ENDPOINT mutation', (done) => { - testAction( + it('should commit SET_JOB_ENDPOINT mutation', () => { + return testAction( setJobEndpoint, 'job/872324.json', mockedState, [{ type: types.SET_JOB_ENDPOINT, payload: 'job/872324.json' }], [], - done, ); }); }); describe('setJobLogOptions', () => { - it('should commit SET_JOB_LOG_OPTIONS mutation', (done) => { - testAction( + it('should commit SET_JOB_LOG_OPTIONS mutation', () => { + return testAction( setJobLogOptions, { pagePath: 'job/872324/trace.json' }, mockedState, [{ type: types.SET_JOB_LOG_OPTIONS, payload: { pagePath: 'job/872324/trace.json' } }], [], - done, ); }); }); describe('hideSidebar', () => { - it('should commit HIDE_SIDEBAR mutation', (done) => { - testAction(hideSidebar, null, mockedState, [{ type: types.HIDE_SIDEBAR }], [], done); + it('should commit HIDE_SIDEBAR mutation', () => { + return testAction(hideSidebar, null, mockedState, [{ type: types.HIDE_SIDEBAR }], []); }); }); describe('showSidebar', () => { - it('should commit HIDE_SIDEBAR mutation', (done) => { - testAction(showSidebar, null, mockedState, [{ type: types.SHOW_SIDEBAR }], [], done); + it('should commit SHOW_SIDEBAR mutation', () => { + return testAction(showSidebar, null, mockedState, [{ type: types.SHOW_SIDEBAR }], []); }); }); describe('toggleSidebar', () => { describe('when isSidebarOpen is true', () => { - it('should dispatch hideSidebar', (done) => { - testAction(toggleSidebar, null, mockedState, [], [{ type: 'hideSidebar' }], done); + it('should dispatch hideSidebar', () => { + return testAction(toggleSidebar, null, mockedState, [], [{ type: 'hideSidebar' }]); }); }); describe('when isSidebarOpen is false', () => { - it('should dispatch showSidebar', (done) => { + it('should dispatch showSidebar', () => { mockedState.isSidebarOpen = false; - testAction(toggleSidebar, null, mockedState, [], [{ type: 'showSidebar' }], done); + return testAction(toggleSidebar, null, mockedState, [], [{ type: 'showSidebar' }]); }); }); }); describe('requestJob', () => { - it('should commit REQUEST_JOB mutation', (done) => { - testAction(requestJob, null, mockedState, [{ type: types.REQUEST_JOB }], [], done); + it('should commit REQUEST_JOB mutation', () => { + return testAction(requestJob, null, mockedState, [{ type: types.REQUEST_JOB }], []); }); }); @@ -113,10 +111,10 @@ describe('Job State actions', () => { }); describe('success', () => { - it('dispatches requestJob and receiveJobSuccess ', (done) => { + it('dispatches requestJob and receiveJobSuccess ', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, { id: 121212, name: 'karma' }); - testAction( + return testAction( fetchJob, null, mockedState, @@ -130,7 +128,6 @@ describe('Job State actions', () => { type: 'receiveJobSuccess', }, ], - done, ); }); }); @@ -140,8 +137,8 @@ describe('Job State actions', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500); }); - it('dispatches requestJob and receiveJobError ', (done) => { - testAction( + it('dispatches requestJob and receiveJobError ', () => { + return testAction( fetchJob, null, mockedState, @@ -154,46 +151,50 @@ describe('Job State actions', () => { type: 'receiveJobError', }, ], - done, ); }); }); }); describe('receiveJobSuccess', () => { - it('should commit RECEIVE_JOB_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_JOB_SUCCESS mutation', () => { + return testAction( receiveJobSuccess, { id: 121232132 }, mockedState, [{ type: types.RECEIVE_JOB_SUCCESS, payload: { id: 121232132 } }], [], - done, ); }); }); describe('receiveJobError', () => { - it('should commit RECEIVE_JOB_ERROR mutation', (done) => { - testAction(receiveJobError, null, mockedState, [{ type: types.RECEIVE_JOB_ERROR }], [], done); + it('should commit RECEIVE_JOB_ERROR mutation', () => { + return testAction( + receiveJobError, + null, + mockedState, + [{ type: types.RECEIVE_JOB_ERROR }], + [], + ); }); }); describe('scrollTop', () => { - it('should dispatch toggleScrollButtons action', (done) => { - testAction(scrollTop, null, mockedState, [], [{ type: 'toggleScrollButtons' }], done); + it('should dispatch toggleScrollButtons action', () => { + return testAction(scrollTop, null, mockedState, [], [{ type: 'toggleScrollButtons' }]); }); }); describe('scrollBottom', () => { - it('should dispatch toggleScrollButtons action', (done) => { - testAction(scrollBottom, null, mockedState, [], [{ type: 'toggleScrollButtons' }], done); + it('should dispatch toggleScrollButtons action', () => { + return testAction(scrollBottom, null, mockedState, [], [{ type: 'toggleScrollButtons' }]); }); }); describe('requestJobLog', () => { - it('should commit REQUEST_JOB_LOG mutation', (done) => { - testAction(requestJobLog, null, mockedState, [{ type: types.REQUEST_JOB_LOG }], [], done); + it('should commit REQUEST_JOB_LOG mutation', () => { + return testAction(requestJobLog, null, mockedState, [{ type: types.REQUEST_JOB_LOG }], []); }); }); @@ -212,13 +213,13 @@ describe('Job State actions', () => { }); describe('success', () => { - it('dispatches requestJobLog, receiveJobLogSuccess and stopPollingJobLog when job is complete', (done) => { + it('dispatches requestJobLog, receiveJobLogSuccess and stopPollingJobLog when job is complete', () => { mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(200, { html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :', complete: true, }); - testAction( + return testAction( fetchJobLog, null, mockedState, @@ -239,7 +240,6 @@ describe('Job State actions', () => { type: 'stopPollingJobLog', }, ], - done, ); }); @@ -255,8 +255,8 @@ describe('Job State actions', () => { mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(200, jobLogPayload); }); - it('dispatches startPollingJobLog', (done) => { - testAction( + it('dispatches startPollingJobLog', () => { + return testAction( fetchJobLog, null, mockedState, @@ -266,14 +266,13 @@ describe('Job State actions', () => { { type: 'receiveJobLogSuccess', payload: jobLogPayload }, { type: 'startPollingJobLog' }, ], - done, ); }); - it('does not dispatch startPollingJobLog when timeout is non-empty', (done) => { + it('does not dispatch startPollingJobLog when timeout is non-empty', () => { mockedState.jobLogTimeout = 1; - testAction( + return testAction( fetchJobLog, null, mockedState, @@ -282,7 +281,6 @@ describe('Job State actions', () => { { type: 'toggleScrollisInBottom', payload: true }, { type: 'receiveJobLogSuccess', payload: jobLogPayload }, ], - done, ); }); }); @@ -293,8 +291,8 @@ describe('Job State actions', () => { mock.onGet(`${TEST_HOST}/endpoint/trace.json`).reply(500); }); - it('dispatches requestJobLog and receiveJobLogError ', (done) => { - testAction( + it('dispatches requestJobLog and receiveJobLogError ', () => { + return testAction( fetchJobLog, null, mockedState, @@ -304,7 +302,6 @@ describe('Job State actions', () => { type: 'receiveJobLogError', }, ], - done, ); }); }); @@ -358,65 +355,58 @@ describe('Job State actions', () => { window.clearTimeout = origTimeout; }); - it('should commit STOP_POLLING_JOB_LOG mutation ', (done) => { + it('should commit STOP_POLLING_JOB_LOG mutation ', async () => { const jobLogTimeout = 7; - testAction( + await testAction( stopPollingJobLog, null, { ...mockedState, jobLogTimeout }, [{ type: types.SET_JOB_LOG_TIMEOUT, payload: 0 }, { type: types.STOP_POLLING_JOB_LOG }], [], - ) - .then(() => { - expect(window.clearTimeout).toHaveBeenCalledWith(jobLogTimeout); - }) - .then(done) - .catch(done.fail); + ); + expect(window.clearTimeout).toHaveBeenCalledWith(jobLogTimeout); }); }); describe('receiveJobLogSuccess', () => { - it('should commit RECEIVE_JOB_LOG_SUCCESS mutation ', (done) => { - testAction( + it('should commit RECEIVE_JOB_LOG_SUCCESS mutation ', () => { + return testAction( receiveJobLogSuccess, 'hello world', mockedState, [{ type: types.RECEIVE_JOB_LOG_SUCCESS, payload: 'hello world' }], [], - done, ); }); }); describe('receiveJobLogError', () => { - it('should commit stop polling job log', (done) => { - testAction(receiveJobLogError, null, mockedState, [], [{ type: 'stopPollingJobLog' }], done); + it('should commit stop polling job log', () => { + return testAction(receiveJobLogError, null, mockedState, [], [{ type: 'stopPollingJobLog' }]); }); }); describe('toggleCollapsibleLine', () => { - it('should commit TOGGLE_COLLAPSIBLE_LINE mutation ', (done) => { - testAction( + it('should commit TOGGLE_COLLAPSIBLE_LINE mutation ', () => { + return testAction( toggleCollapsibleLine, { isClosed: true }, mockedState, [{ type: types.TOGGLE_COLLAPSIBLE_LINE, payload: { isClosed: true } }], [], - done, ); }); }); describe('requestJobsForStage', () => { - it('should commit REQUEST_JOBS_FOR_STAGE mutation ', (done) => { - testAction( + it('should commit REQUEST_JOBS_FOR_STAGE mutation ', () => { + return testAction( requestJobsForStage, { name: 'deploy' }, mockedState, [{ type: types.REQUEST_JOBS_FOR_STAGE, payload: { name: 'deploy' } }], [], - done, ); }); }); @@ -433,12 +423,12 @@ describe('Job State actions', () => { }); describe('success', () => { - it('dispatches requestJobsForStage and receiveJobsForStageSuccess ', (done) => { + it('dispatches requestJobsForStage and receiveJobsForStageSuccess ', () => { mock .onGet(`${TEST_HOST}/jobs.json`) .replyOnce(200, { latest_statuses: [{ id: 121212, name: 'build' }], retried: [] }); - testAction( + return testAction( fetchJobsForStage, { dropdown_path: `${TEST_HOST}/jobs.json` }, mockedState, @@ -453,7 +443,6 @@ describe('Job State actions', () => { type: 'receiveJobsForStageSuccess', }, ], - done, ); }); }); @@ -463,8 +452,8 @@ describe('Job State actions', () => { mock.onGet(`${TEST_HOST}/jobs.json`).reply(500); }); - it('dispatches requestJobsForStage and receiveJobsForStageError', (done) => { - testAction( + it('dispatches requestJobsForStage and receiveJobsForStageError', () => { + return testAction( fetchJobsForStage, { dropdown_path: `${TEST_HOST}/jobs.json` }, mockedState, @@ -478,34 +467,31 @@ describe('Job State actions', () => { type: 'receiveJobsForStageError', }, ], - done, ); }); }); }); describe('receiveJobsForStageSuccess', () => { - it('should commit RECEIVE_JOBS_FOR_STAGE_SUCCESS mutation ', (done) => { - testAction( + it('should commit RECEIVE_JOBS_FOR_STAGE_SUCCESS mutation ', () => { + return testAction( receiveJobsForStageSuccess, [{ id: 121212, name: 'karma' }], mockedState, [{ type: types.RECEIVE_JOBS_FOR_STAGE_SUCCESS, payload: [{ id: 121212, name: 'karma' }] }], [], - done, ); }); }); describe('receiveJobsForStageError', () => { - it('should commit RECEIVE_JOBS_FOR_STAGE_ERROR mutation ', (done) => { - testAction( + it('should commit RECEIVE_JOBS_FOR_STAGE_ERROR mutation ', () => { + return testAction( receiveJobsForStageError, null, mockedState, [{ type: types.RECEIVE_JOBS_FOR_STAGE_ERROR }], [], - done, ); }); }); diff --git a/spec/frontend/labels/components/promote_label_modal_spec.js b/spec/frontend/labels/components/promote_label_modal_spec.js index d2fbdfc9a8d..8cfaba6f98a 100644 --- a/spec/frontend/labels/components/promote_label_modal_spec.js +++ b/spec/frontend/labels/components/promote_label_modal_spec.js @@ -50,7 +50,7 @@ describe('Promote label modal', () => { vm.$destroy(); }); - it('redirects when a label is promoted', (done) => { + it('redirects when a label is promoted', () => { const responseURL = `${TEST_HOST}/dummy/endpoint`; jest.spyOn(axios, 'post').mockImplementation((url) => { expect(url).toBe(labelMockData.url); @@ -65,39 +65,35 @@ describe('Promote label modal', () => { }); }); - vm.onSubmit() - .then(() => { - expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', { - labelUrl: labelMockData.url, - successful: true, - }); - }) - .then(done) - .catch(done.fail); + return vm.onSubmit().then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', { + labelUrl: labelMockData.url, + successful: true, + }); + }); }); - it('displays an error if promoting a label failed', (done) => { + it('displays an error if promoting a label failed', () => { const dummyError = new Error('promoting label failed'); dummyError.response = { status: 500 }; + jest.spyOn(axios, 'post').mockImplementation((url) => { expect(url).toBe(labelMockData.url); expect(eventHub.$emit).toHaveBeenCalledWith( 'promoteLabelModal.requestStarted', labelMockData.url, ); + return Promise.reject(dummyError); }); - vm.onSubmit() - .catch((error) => { - expect(error).toBe(dummyError); - expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', { - labelUrl: labelMockData.url, - successful: false, - }); - }) - .then(done) - .catch(done.fail); + return vm.onSubmit().catch((error) => { + expect(error).toBe(dummyError); + expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', { + labelUrl: labelMockData.url, + successful: false, + }); + }); }); }); }); diff --git a/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js b/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js index 971ba8b583c..5ac7a7985a8 100644 --- a/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js +++ b/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js @@ -82,34 +82,39 @@ describe('getSuppressNetworkErrorsDuringNavigationLink', () => { isNavigatingAway.mockReturnValue(false); }); - it('forwards successful requests', (done) => { + it('forwards successful requests', () => { createSubscription(makeMockSuccessLink(), { next({ data }) { expect(data).toEqual({ foo: { id: 1 } }); }, - error: () => done.fail('Should not happen'), - complete: () => done(), + error: () => { + throw new Error('Should not happen'); + }, }); }); - it('forwards GraphQL errors', (done) => { + it('forwards GraphQL errors', () => { createSubscription(makeMockGraphQLErrorLink(), { next({ errors }) { expect(errors).toEqual([{ message: 'foo' }]); }, - error: () => done.fail('Should not happen'), - complete: () => done(), + error: () => { + throw new Error('Should not happen'); + }, }); }); - it('forwards network errors', (done) => { + it('forwards network errors', () => { createSubscription(makeMockNetworkErrorLink(), { - next: () => done.fail('Should not happen'), + next: () => { + throw new Error('Should not happen'); + }, error: (error) => { expect(error.message).toBe('NetworkError'); - done(); }, - complete: () => done.fail('Should not happen'), + complete: () => { + throw new Error('Should not happen'); + }, }); }); }); @@ -119,23 +124,25 @@ describe('getSuppressNetworkErrorsDuringNavigationLink', () => { isNavigatingAway.mockReturnValue(true); }); - it('forwards successful requests', (done) => { + it('forwards successful requests', () => { createSubscription(makeMockSuccessLink(), { next({ data }) { expect(data).toEqual({ foo: { id: 1 } }); }, - error: () => done.fail('Should not happen'), - complete: () => done(), + error: () => { + throw new Error('Should not happen'); + }, }); }); - it('forwards GraphQL errors', (done) => { + it('forwards GraphQL errors', () => { createSubscription(makeMockGraphQLErrorLink(), { next({ errors }) { expect(errors).toEqual([{ message: 'foo' }]); }, - error: () => done.fail('Should not happen'), - complete: () => done(), + error: () => { + throw new Error('Should not happen'); + }, }); }); }); diff --git a/spec/frontend/lib/gfm/index_spec.js b/spec/frontend/lib/gfm/index_spec.js new file mode 100644 index 00000000000..5c72b5a51a7 --- /dev/null +++ b/spec/frontend/lib/gfm/index_spec.js @@ -0,0 +1,46 @@ +import { render } from '~/lib/gfm'; + +describe('gfm', () => { + describe('render', () => { + it('processes Commonmark and provides an ast to the renderer function', async () => { + let result; + + await render({ + markdown: 'This is text', + renderer: (tree) => { + result = tree; + }, + }); + + expect(result.type).toBe('root'); + }); + + it('transforms raw HTML into individual nodes in the AST', async () => { + let result; + + await render({ + markdown: '<strong>This is bold text</strong>', + renderer: (tree) => { + result = tree; + }, + }); + + expect(result.children[0].children[0]).toMatchObject({ + type: 'element', + tagName: 'strong', + properties: {}, + }); + }); + + it('returns the result of executing the renderer function', async () => { + const result = await render({ + markdown: '<strong>This is bold text</strong>', + renderer: () => { + return 'rendered tree'; + }, + }); + + expect(result).toBe('rendered tree'); + }); + }); +}); diff --git a/spec/frontend/lib/utils/apollo_startup_js_link_spec.js b/spec/frontend/lib/utils/apollo_startup_js_link_spec.js index e58bc063004..06573f346e0 100644 --- a/spec/frontend/lib/utils/apollo_startup_js_link_spec.js +++ b/spec/frontend/lib/utils/apollo_startup_js_link_spec.js @@ -58,17 +58,16 @@ describe('StartupJSLink', () => { link = ApolloLink.from([startupLink, new ApolloLink(() => Observable.of(FORWARDED_RESPONSE))]); }; - it('forwards requests if no calls are set up', (done) => { + it('forwards requests if no calls are set up', () => { setupLink(); link.request(mockOperation()).subscribe((result) => { expect(result).toEqual(FORWARDED_RESPONSE); expect(startupLink.startupCalls).toBe(null); expect(startupLink.request).toEqual(StartupJSLink.noopRequest); - done(); }); }); - it('forwards requests if the operation is not pre-loaded', (done) => { + it('forwards requests if the operation is not pre-loaded', () => { window.gl = { startup_graphql_calls: [ { @@ -82,12 +81,11 @@ describe('StartupJSLink', () => { link.request(mockOperation({ operationName: 'notLoaded' })).subscribe((result) => { expect(result).toEqual(FORWARDED_RESPONSE); expect(startupLink.startupCalls.size).toBe(1); - done(); }); }); describe('variable match errors: ', () => { - it('forwards requests if the variables are not matching', (done) => { + it('forwards requests if the variables are not matching', () => { window.gl = { startup_graphql_calls: [ { @@ -101,11 +99,10 @@ describe('StartupJSLink', () => { link.request(mockOperation()).subscribe((result) => { expect(result).toEqual(FORWARDED_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); - it('forwards requests if more variables are set in the operation', (done) => { + it('forwards requests if more variables are set in the operation', () => { window.gl = { startup_graphql_calls: [ { @@ -118,11 +115,10 @@ describe('StartupJSLink', () => { link.request(mockOperation()).subscribe((result) => { expect(result).toEqual(FORWARDED_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); - it('forwards requests if less variables are set in the operation', (done) => { + it('forwards requests if less variables are set in the operation', () => { window.gl = { startup_graphql_calls: [ { @@ -136,11 +132,10 @@ describe('StartupJSLink', () => { link.request(mockOperation({ variables: { id: 3 } })).subscribe((result) => { expect(result).toEqual(FORWARDED_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); - it('forwards requests if different variables are set', (done) => { + it('forwards requests if different variables are set', () => { window.gl = { startup_graphql_calls: [ { @@ -154,11 +149,10 @@ describe('StartupJSLink', () => { link.request(mockOperation({ variables: { id: 3 } })).subscribe((result) => { expect(result).toEqual(FORWARDED_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); - it('forwards requests if array variables have a different order', (done) => { + it('forwards requests if array variables have a different order', () => { window.gl = { startup_graphql_calls: [ { @@ -172,13 +166,12 @@ describe('StartupJSLink', () => { link.request(mockOperation({ variables: { id: [4, 3] } })).subscribe((result) => { expect(result).toEqual(FORWARDED_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); }); describe('error handling', () => { - it('forwards the call if the fetchCall is failing with a HTTP Error', (done) => { + it('forwards the call if the fetchCall is failing with a HTTP Error', () => { window.gl = { startup_graphql_calls: [ { @@ -192,11 +185,10 @@ describe('StartupJSLink', () => { link.request(mockOperation()).subscribe((result) => { expect(result).toEqual(FORWARDED_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); - it('forwards the call if it errors (e.g. failing JSON)', (done) => { + it('forwards the call if it errors (e.g. failing JSON)', () => { window.gl = { startup_graphql_calls: [ { @@ -210,11 +202,10 @@ describe('StartupJSLink', () => { link.request(mockOperation()).subscribe((result) => { expect(result).toEqual(FORWARDED_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); - it('forwards the call if the response contains an error', (done) => { + it('forwards the call if the response contains an error', () => { window.gl = { startup_graphql_calls: [ { @@ -228,11 +219,10 @@ describe('StartupJSLink', () => { link.request(mockOperation()).subscribe((result) => { expect(result).toEqual(FORWARDED_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); - it("forwards the call if the response doesn't contain a data object", (done) => { + it("forwards the call if the response doesn't contain a data object", () => { window.gl = { startup_graphql_calls: [ { @@ -246,12 +236,11 @@ describe('StartupJSLink', () => { link.request(mockOperation()).subscribe((result) => { expect(result).toEqual(FORWARDED_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); }); - it('resolves the request if the operation is matching', (done) => { + it('resolves the request if the operation is matching', () => { window.gl = { startup_graphql_calls: [ { @@ -265,11 +254,10 @@ describe('StartupJSLink', () => { link.request(mockOperation()).subscribe((result) => { expect(result).toEqual(STARTUP_JS_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); - it('resolves the request exactly once', (done) => { + it('resolves the request exactly once', () => { window.gl = { startup_graphql_calls: [ { @@ -285,12 +273,11 @@ describe('StartupJSLink', () => { expect(startupLink.startupCalls.size).toBe(0); link.request(mockOperation()).subscribe((result2) => { expect(result2).toEqual(FORWARDED_RESPONSE); - done(); }); }); }); - it('resolves the request if the variables have a different order', (done) => { + it('resolves the request if the variables have a different order', () => { window.gl = { startup_graphql_calls: [ { @@ -304,11 +291,10 @@ describe('StartupJSLink', () => { link.request(mockOperation({ variables: { name: 'foo', id: 3 } })).subscribe((result) => { expect(result).toEqual(STARTUP_JS_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); - it('resolves the request if the variables have undefined values', (done) => { + it('resolves the request if the variables have undefined values', () => { window.gl = { startup_graphql_calls: [ { @@ -324,11 +310,10 @@ describe('StartupJSLink', () => { .subscribe((result) => { expect(result).toEqual(STARTUP_JS_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); - it('resolves the request if the variables are of an array format', (done) => { + it('resolves the request if the variables are of an array format', () => { window.gl = { startup_graphql_calls: [ { @@ -342,11 +327,10 @@ describe('StartupJSLink', () => { link.request(mockOperation({ variables: { id: [3, 4] } })).subscribe((result) => { expect(result).toEqual(STARTUP_JS_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); - it('resolves multiple requests correctly', (done) => { + it('resolves multiple requests correctly', () => { window.gl = { startup_graphql_calls: [ { @@ -368,7 +352,6 @@ describe('StartupJSLink', () => { link.request(mockOperation({ operationName: OPERATION_NAME })).subscribe((result2) => { expect(result2).toEqual(STARTUP_JS_RESPONSE); expect(startupLink.startupCalls.size).toBe(0); - done(); }); }); }); diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js index 0be0bf89210..763a9bd30fe 100644 --- a/spec/frontend/lib/utils/common_utils_spec.js +++ b/spec/frontend/lib/utils/common_utils_spec.js @@ -266,15 +266,18 @@ describe('common_utils', () => { }); describe('debounceByAnimationFrame', () => { - it('debounces a function to allow a maximum of one call per animation frame', (done) => { + it('debounces a function to allow a maximum of one call per animation frame', () => { const spy = jest.fn(); const debouncedSpy = commonUtils.debounceByAnimationFrame(spy); - window.requestAnimationFrame(() => { - debouncedSpy(); - debouncedSpy(); + + return new Promise((resolve) => { window.requestAnimationFrame(() => { - expect(spy).toHaveBeenCalledTimes(1); - done(); + debouncedSpy(); + debouncedSpy(); + window.requestAnimationFrame(() => { + expect(spy).toHaveBeenCalledTimes(1); + resolve(); + }); }); }); }); @@ -372,28 +375,24 @@ describe('common_utils', () => { jest.spyOn(window, 'setTimeout'); }); - it('solves the promise from the callback', (done) => { + it('solves the promise from the callback', () => { const expectedResponseValue = 'Success!'; - commonUtils + return commonUtils .backOff((next, stop) => new Promise((resolve) => { resolve(expectedResponseValue); - }) - .then((resp) => { - stop(resp); - }) - .catch(done.fail), + }).then((resp) => { + stop(resp); + }), ) .then((respBackoff) => { expect(respBackoff).toBe(expectedResponseValue); - done(); - }) - .catch(done.fail); + }); }); - it('catches the rejected promise from the callback ', (done) => { + it('catches the rejected promise from the callback ', () => { const errorMessage = 'Mistakes were made!'; - commonUtils + return commonUtils .backOff((next, stop) => { new Promise((resolve, reject) => { reject(new Error(errorMessage)); @@ -406,39 +405,34 @@ describe('common_utils', () => { .catch((errBackoffResp) => { expect(errBackoffResp instanceof Error).toBe(true); expect(errBackoffResp.message).toBe(errorMessage); - done(); }); }); - it('solves the promise correctly after retrying a third time', (done) => { + it('solves the promise correctly after retrying a third time', () => { let numberOfCalls = 1; const expectedResponseValue = 'Success!'; - commonUtils + return commonUtils .backOff((next, stop) => - Promise.resolve(expectedResponseValue) - .then((resp) => { - if (numberOfCalls < 3) { - numberOfCalls += 1; - next(); - jest.runOnlyPendingTimers(); - } else { - stop(resp); - } - }) - .catch(done.fail), + Promise.resolve(expectedResponseValue).then((resp) => { + if (numberOfCalls < 3) { + numberOfCalls += 1; + next(); + jest.runOnlyPendingTimers(); + } else { + stop(resp); + } + }), ) .then((respBackoff) => { const timeouts = window.setTimeout.mock.calls.map(([, timeout]) => timeout); expect(timeouts).toEqual([2000, 4000]); expect(respBackoff).toBe(expectedResponseValue); - done(); - }) - .catch(done.fail); + }); }); - it('rejects the backOff promise after timing out', (done) => { - commonUtils + it('rejects the backOff promise after timing out', () => { + return commonUtils .backOff((next) => { next(); jest.runOnlyPendingTimers(); @@ -449,7 +443,6 @@ describe('common_utils', () => { expect(timeouts).toEqual([2000, 4000, 8000, 16000, 32000, 32000]); expect(errBackoffResp instanceof Error).toBe(true); expect(errBackoffResp.message).toBe('BACKOFF_TIMEOUT'); - done(); }); }); }); diff --git a/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js b/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js index e06d1384610..d6131b1a1d7 100644 --- a/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js +++ b/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js @@ -5,12 +5,23 @@ import ConfirmModal from '~/lib/utils/confirm_via_gl_modal/confirm_modal.vue'; describe('Confirm Modal', () => { let wrapper; let modal; + const SECONDARY_TEXT = 'secondaryText'; + const SECONDARY_VARIANT = 'danger'; - const createComponent = ({ primaryText, primaryVariant, title, hideCancel = false } = {}) => { + const createComponent = ({ + primaryText, + primaryVariant, + secondaryText, + secondaryVariant, + title, + hideCancel = false, + } = {}) => { wrapper = mount(ConfirmModal, { propsData: { primaryText, primaryVariant, + secondaryText, + secondaryVariant, hideCancel, title, }, @@ -65,6 +76,19 @@ describe('Confirm Modal', () => { expect(props.actionCancel).toBeNull(); }); + it('should not show secondary Button when secondary Text is not set', () => { + createComponent(); + const props = findGlModal().props(); + expect(props.actionSecondary).toBeNull(); + }); + + it('should show secondary Button when secondaryText is set', () => { + createComponent({ secondaryText: SECONDARY_TEXT, secondaryVariant: SECONDARY_VARIANT }); + const actionSecondary = findGlModal().props('actionSecondary'); + expect(actionSecondary.text).toEqual(SECONDARY_TEXT); + expect(actionSecondary.attributes.variant).toEqual(SECONDARY_VARIANT); + }); + it('should set the modal title when the `title` prop is set', () => { const title = 'Modal title'; createComponent({ title }); diff --git a/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js b/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js new file mode 100644 index 00000000000..47bb512cbb5 --- /dev/null +++ b/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js @@ -0,0 +1,17 @@ +import { newDateAsLocaleTime } from '~/lib/utils/datetime/date_calculation_utility'; + +describe('newDateAsLocaleTime', () => { + it.each` + string | expected + ${'2022-03-22'} | ${new Date('2022-03-22T00:00:00.000Z')} + ${'2022-03-22T00:00:00.000Z'} | ${new Date('2022-03-22T00:00:00.000Z')} + ${2022} | ${null} + ${[]} | ${null} + ${{}} | ${null} + ${true} | ${null} + ${null} | ${null} + ${undefined} | ${null} + `('returns $expected given $string', ({ string, expected }) => { + expect(newDateAsLocaleTime(string)).toEqual(expected); + }); +}); diff --git a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js index 1adc70450e8..018ae12c908 100644 --- a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js +++ b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js @@ -133,3 +133,15 @@ describe('formatTimeAsSummary', () => { expect(utils.formatTimeAsSummary({ [unit]: value })).toBe(result); }); }); + +describe('durationTimeFormatted', () => { + it.each` + duration | expectedOutput + ${87} | ${'00:01:27'} + ${141} | ${'00:02:21'} + ${12} | ${'00:00:12'} + ${60} | ${'00:01:00'} + `('returns $expectedOutput when provided $duration', ({ duration, expectedOutput }) => { + expect(utils.durationTimeFormatted(duration)).toBe(expectedOutput); + }); +}); diff --git a/spec/frontend/lib/utils/datetime/timeago_utility_spec.js b/spec/frontend/lib/utils/datetime/timeago_utility_spec.js index 2314ec678d3..1ef7047d959 100644 --- a/spec/frontend/lib/utils/datetime/timeago_utility_spec.js +++ b/spec/frontend/lib/utils/datetime/timeago_utility_spec.js @@ -1,4 +1,4 @@ -import { getTimeago, localTimeAgo, timeFor } from '~/lib/utils/datetime/timeago_utility'; +import { getTimeago, localTimeAgo, timeFor, duration } from '~/lib/utils/datetime/timeago_utility'; import { s__ } from '~/locale'; import '~/commons/bootstrap'; @@ -66,6 +66,54 @@ describe('TimeAgo utils', () => { }); }); + describe('duration', () => { + const ONE_DAY = 24 * 60 * 60; + + it.each` + secs | formatted + ${0} | ${'0 seconds'} + ${30} | ${'30 seconds'} + ${59} | ${'59 seconds'} + ${60} | ${'1 minute'} + ${-60} | ${'1 minute'} + ${2 * 60} | ${'2 minutes'} + ${60 * 60} | ${'1 hour'} + ${2 * 60 * 60} | ${'2 hours'} + ${ONE_DAY} | ${'1 day'} + ${2 * ONE_DAY} | ${'2 days'} + ${7 * ONE_DAY} | ${'1 week'} + ${14 * ONE_DAY} | ${'2 weeks'} + ${31 * ONE_DAY} | ${'1 month'} + ${61 * ONE_DAY} | ${'2 months'} + ${365 * ONE_DAY} | ${'1 year'} + ${365 * 2 * ONE_DAY} | ${'2 years'} + `('formats $secs as "$formatted"', ({ secs, formatted }) => { + const ms = secs * 1000; + + expect(duration(ms)).toBe(formatted); + }); + + // `duration` can be used to format Rails month durations. + // Ensure formatting for quantities such as `2.months.to_i` + // based on ActiveSupport::Duration::SECONDS_PER_MONTH. + // See: https://api.rubyonrails.org/classes/ActiveSupport/Duration.html + const SECONDS_PER_MONTH = 2629746; // 1.month.to_i + + it.each` + duration | secs | formatted + ${'1.month'} | ${SECONDS_PER_MONTH} | ${'1 month'} + ${'2.months'} | ${SECONDS_PER_MONTH * 2} | ${'2 months'} + ${'3.months'} | ${SECONDS_PER_MONTH * 3} | ${'3 months'} + `( + 'formats ActiveSupport::Duration of `$duration` ($secs) as "$formatted"', + ({ secs, formatted }) => { + const ms = secs * 1000; + + expect(duration(ms)).toBe(formatted); + }, + ); + }); + describe('localTimeAgo', () => { beforeEach(() => { document.body.innerHTML = diff --git a/spec/frontend/lib/utils/poll_spec.js b/spec/frontend/lib/utils/poll_spec.js index 861808e3ad8..1f150599983 100644 --- a/spec/frontend/lib/utils/poll_spec.js +++ b/spec/frontend/lib/utils/poll_spec.js @@ -50,58 +50,48 @@ describe('Poll', () => { }; }); - it('calls the success callback when no header for interval is provided', (done) => { + it('calls the success callback when no header for interval is provided', () => { mockServiceCall({ status: 200 }); setup(); - waitForAllCallsToFinish(1, () => { + return waitForAllCallsToFinish(1, () => { expect(callbacks.success).toHaveBeenCalled(); expect(callbacks.error).not.toHaveBeenCalled(); - - done(); }); }); - it('calls the error callback when the http request returns an error', (done) => { + it('calls the error callback when the http request returns an error', () => { mockServiceCall({ status: 500 }, true); setup(); - waitForAllCallsToFinish(1, () => { + return waitForAllCallsToFinish(1, () => { expect(callbacks.success).not.toHaveBeenCalled(); expect(callbacks.error).toHaveBeenCalled(); - - done(); }); }); - it('skips the error callback when request is aborted', (done) => { + it('skips the error callback when request is aborted', () => { mockServiceCall({ status: 0 }, true); setup(); - waitForAllCallsToFinish(1, () => { + return waitForAllCallsToFinish(1, () => { expect(callbacks.success).not.toHaveBeenCalled(); expect(callbacks.error).not.toHaveBeenCalled(); expect(callbacks.notification).toHaveBeenCalled(); - - done(); }); }); - it('should call the success callback when the interval header is -1', (done) => { + it('should call the success callback when the interval header is -1', () => { mockServiceCall({ status: 200, headers: { 'poll-interval': -1 } }); - setup() - .then(() => { - expect(callbacks.success).toHaveBeenCalled(); - expect(callbacks.error).not.toHaveBeenCalled(); - - done(); - }) - .catch(done.fail); + return setup().then(() => { + expect(callbacks.success).toHaveBeenCalled(); + expect(callbacks.error).not.toHaveBeenCalled(); + }); }); describe('for 2xx status code', () => { successCodes.forEach((httpCode) => { - it(`starts polling when http status is ${httpCode} and interval header is provided`, (done) => { + it(`starts polling when http status is ${httpCode} and interval header is provided`, () => { mockServiceCall({ status: httpCode, headers: { 'poll-interval': 1 } }); const Polling = new Poll({ @@ -114,22 +104,20 @@ describe('Poll', () => { Polling.makeRequest(); - waitForAllCallsToFinish(2, () => { + return waitForAllCallsToFinish(2, () => { Polling.stop(); expect(service.fetch.mock.calls).toHaveLength(2); expect(service.fetch).toHaveBeenCalledWith({ page: 1 }); expect(callbacks.success).toHaveBeenCalled(); expect(callbacks.error).not.toHaveBeenCalled(); - - done(); }); }); }); }); describe('with delayed initial request', () => { - it('delays the first request', async (done) => { + it('delays the first request', async () => { mockServiceCall({ status: 200, headers: { 'poll-interval': 1 } }); const Polling = new Poll({ @@ -144,21 +132,19 @@ describe('Poll', () => { expect(Polling.timeoutID).toBeTruthy(); - waitForAllCallsToFinish(2, () => { + return waitForAllCallsToFinish(2, () => { Polling.stop(); expect(service.fetch.mock.calls).toHaveLength(2); expect(service.fetch).toHaveBeenCalledWith({ page: 1 }); expect(callbacks.success).toHaveBeenCalled(); expect(callbacks.error).not.toHaveBeenCalled(); - - done(); }); }); }); describe('stop', () => { - it('stops polling when method is called', (done) => { + it('stops polling when method is called', () => { mockServiceCall({ status: 200, headers: { 'poll-interval': 1 } }); const Polling = new Poll({ @@ -175,18 +161,16 @@ describe('Poll', () => { Polling.makeRequest(); - waitForAllCallsToFinish(1, () => { + return waitForAllCallsToFinish(1, () => { expect(service.fetch.mock.calls).toHaveLength(1); expect(service.fetch).toHaveBeenCalledWith({ page: 1 }); expect(Polling.stop).toHaveBeenCalled(); - - done(); }); }); }); describe('enable', () => { - it('should enable polling upon a response', (done) => { + it('should enable polling upon a response', () => { mockServiceCall({ status: 200 }); const Polling = new Poll({ resource: service, @@ -200,19 +184,18 @@ describe('Poll', () => { response: { status: 200, headers: { 'poll-interval': 1 } }, }); - waitForAllCallsToFinish(1, () => { + return waitForAllCallsToFinish(1, () => { Polling.stop(); expect(service.fetch.mock.calls).toHaveLength(1); expect(service.fetch).toHaveBeenCalledWith({ page: 4 }); expect(Polling.options.data).toEqual({ page: 4 }); - done(); }); }); }); describe('restart', () => { - it('should restart polling when its called', (done) => { + it('should restart polling when its called', () => { mockServiceCall({ status: 200, headers: { 'poll-interval': 1 } }); const Polling = new Poll({ @@ -238,7 +221,7 @@ describe('Poll', () => { Polling.makeRequest(); - waitForAllCallsToFinish(2, () => { + return waitForAllCallsToFinish(2, () => { Polling.stop(); expect(service.fetch.mock.calls).toHaveLength(2); @@ -247,7 +230,6 @@ describe('Poll', () => { expect(Polling.enable).toHaveBeenCalled(); expect(Polling.restart).toHaveBeenCalled(); expect(Polling.options.data).toEqual({ page: 4 }); - done(); }); }); }); diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js index a5877aa6e3e..103305f0797 100644 --- a/spec/frontend/lib/utils/text_markdown_spec.js +++ b/spec/frontend/lib/utils/text_markdown_spec.js @@ -178,12 +178,23 @@ describe('init markdown', () => { it.each` text | expected ${'- item'} | ${'- item\n- '} + ${'* item'} | ${'* item\n* '} + ${'+ item'} | ${'+ item\n+ '} ${'- [ ] item'} | ${'- [ ] item\n- [ ] '} - ${'- [x] item'} | ${'- [x] item\n- [x] '} + ${'- [x] item'} | ${'- [x] item\n- [ ] '} + ${'- [X] item'} | ${'- [X] item\n- [ ] '} + ${'- [ ] nbsp (U+00A0)'} | ${'- [ ] nbsp (U+00A0)\n- [ ] '} ${'- item\n - second'} | ${'- item\n - second\n - '} + ${'- - -'} | ${'- - -'} + ${'- --'} | ${'- --'} + ${'* **'} | ${'* **'} + ${' ** * ** * ** * **'} | ${' ** * ** * ** * **'} + ${'- - -x'} | ${'- - -x\n- '} + ${'+ ++'} | ${'+ ++\n+ '} ${'1. item'} | ${'1. item\n2. '} ${'1. [ ] item'} | ${'1. [ ] item\n2. [ ] '} - ${'1. [x] item'} | ${'1. [x] item\n2. [x] '} + ${'1. [x] item'} | ${'1. [x] item\n2. [ ] '} + ${'1. [X] item'} | ${'1. [X] item\n2. [ ] '} ${'108. item'} | ${'108. item\n109. '} ${'108. item\n - second'} | ${'108. item\n - second\n - '} ${'108. item\n 1. second'} | ${'108. item\n 1. second\n 2. '} @@ -207,10 +218,12 @@ describe('init markdown', () => { ${'- item\n- '} | ${'- item\n'} ${'- [ ] item\n- [ ] '} | ${'- [ ] item\n'} ${'- [x] item\n- [x] '} | ${'- [x] item\n'} + ${'- [X] item\n- [X] '} | ${'- [X] item\n'} ${'- item\n - second\n - '} | ${'- item\n - second\n'} ${'1. item\n2. '} | ${'1. item\n'} ${'1. [ ] item\n2. [ ] '} | ${'1. [ ] item\n'} ${'1. [x] item\n2. [x] '} | ${'1. [x] item\n'} + ${'1. [X] item\n2. [X] '} | ${'1. [X] item\n'} ${'108. item\n109. '} | ${'108. item\n'} ${'108. item\n - second\n - '} | ${'108. item\n - second\n'} ${'108. item\n 1. second\n 1. '} | ${'108. item\n 1. second\n'} diff --git a/spec/frontend/lib/utils/unit_format/formatter_factory_spec.js b/spec/frontend/lib/utils/unit_format/formatter_factory_spec.js index 0ca70e0a77e..9632d0f98f4 100644 --- a/spec/frontend/lib/utils/unit_format/formatter_factory_spec.js +++ b/spec/frontend/lib/utils/unit_format/formatter_factory_spec.js @@ -31,12 +31,17 @@ describe('unit_format/formatter_factory', () => { expect(formatNumber(12.345, 4)).toBe('12.3450'); }); - it('formats a large integer with a length limit', () => { + it('formats a large integer with a max length - using legacy positional argument', () => { expect(formatNumber(10 ** 7, undefined)).toBe('10,000,000'); expect(formatNumber(10 ** 7, undefined, 9)).toBe('1.00e+7'); expect(formatNumber(10 ** 7, undefined, 10)).toBe('10,000,000'); }); + it('formats a large integer with a max length', () => { + expect(formatNumber(10 ** 7, undefined, { maxLength: 9 })).toBe('1.00e+7'); + expect(formatNumber(10 ** 7, undefined, { maxLength: 10 })).toBe('10,000,000'); + }); + describe('formats with a different locale', () => { let originalLang; @@ -92,7 +97,7 @@ describe('unit_format/formatter_factory', () => { expect(formatSuffix(-1000000)).toBe('-1,000,000pop.'); }); - it('formats a floating point nugative number', () => { + it('formats a floating point negative number', () => { expect(formatSuffix(-0.1)).toBe('-0.1pop.'); expect(formatSuffix(-0.1, 0)).toBe('-0pop.'); expect(formatSuffix(-0.1, 2)).toBe('-0.10pop.'); @@ -108,10 +113,20 @@ describe('unit_format/formatter_factory', () => { expect(formatSuffix(10 ** 10)).toBe('10,000,000,000pop.'); }); - it('formats a large integer with a length limit', () => { + it('formats using a unit separator', () => { + expect(formatSuffix(10, 0, { unitSeparator: ' ' })).toBe('10 pop.'); + expect(formatSuffix(10, 0, { unitSeparator: ' x ' })).toBe('10 x pop.'); + }); + + it('formats a large integer with a max length - using legacy positional argument', () => { expect(formatSuffix(10 ** 7, undefined, 10)).toBe('1.00e+7pop.'); expect(formatSuffix(10 ** 10, undefined, 10)).toBe('1.00e+10pop.'); }); + + it('formats a large integer with a max length', () => { + expect(formatSuffix(10 ** 7, undefined, { maxLength: 10 })).toBe('1.00e+7pop.'); + expect(formatSuffix(10 ** 10, undefined, { maxLength: 10 })).toBe('1.00e+10pop.'); + }); }); describe('scaledSIFormatter', () => { @@ -143,6 +158,10 @@ describe('unit_format/formatter_factory', () => { expect(formatGibibytes(10 ** 10)).toBe('10GB'); expect(formatGibibytes(10 ** 11)).toBe('100GB'); }); + + it('formats bytes using a unit separator', () => { + expect(formatGibibytes(1, 0, { unitSeparator: ' ' })).toBe('1 B'); + }); }); describe('scaled format with offset', () => { @@ -174,6 +193,19 @@ describe('unit_format/formatter_factory', () => { expect(formatGigaBytes(10 ** 9)).toBe('1EB'); }); + it('formats bytes using a unit separator', () => { + expect(formatGigaBytes(1, undefined, { unitSeparator: ' ' })).toBe('1 GB'); + }); + + it('formats long byte numbers with max length - using legacy positional argument', () => { + expect(formatGigaBytes(1, 8, 7)).toBe('1.00e+0GB'); + }); + + it('formats long byte numbers with max length', () => { + expect(formatGigaBytes(1, 8)).toBe('1.00000000GB'); + expect(formatGigaBytes(1, 8, { maxLength: 7 })).toBe('1.00e+0GB'); + }); + it('formatting of too large numbers is not suported', () => { // formatting YB is out of range expect(() => scaledSIFormatter('B', 9)).toThrow(); @@ -216,6 +248,10 @@ describe('unit_format/formatter_factory', () => { expect(formatMilligrams(-100)).toBe('-100mg'); expect(formatMilligrams(-(10 ** 4))).toBe('-10g'); }); + + it('formats using a unit separator', () => { + expect(formatMilligrams(1, undefined, { unitSeparator: ' ' })).toBe('1 mg'); + }); }); }); @@ -253,6 +289,10 @@ describe('unit_format/formatter_factory', () => { expect(formatScaledBin(10 * 1024 ** 3)).toBe('10GiB'); expect(formatScaledBin(100 * 1024 ** 3)).toBe('100GiB'); }); + + it('formats using a unit separator', () => { + expect(formatScaledBin(1, undefined, { unitSeparator: ' ' })).toBe('1 B'); + }); }); describe('scaled format with offset', () => { @@ -288,6 +328,10 @@ describe('unit_format/formatter_factory', () => { expect(formatGibibytes(100 * 1024 ** 3)).toBe('100EiB'); }); + it('formats using a unit separator', () => { + expect(formatGibibytes(1, undefined, { unitSeparator: ' ' })).toBe('1 GiB'); + }); + it('formatting of too large numbers is not suported', () => { // formatting YB is out of range expect(() => scaledBinaryFormatter('B', 9)).toThrow(); diff --git a/spec/frontend/lib/utils/unit_format/index_spec.js b/spec/frontend/lib/utils/unit_format/index_spec.js index 7fd273f1b58..dc9d6ece48e 100644 --- a/spec/frontend/lib/utils/unit_format/index_spec.js +++ b/spec/frontend/lib/utils/unit_format/index_spec.js @@ -74,10 +74,13 @@ describe('unit_format', () => { it('seconds', () => { expect(seconds(1)).toBe('1s'); + expect(seconds(1, undefined, { unitSeparator: ' ' })).toBe('1 s'); }); it('milliseconds', () => { expect(milliseconds(1)).toBe('1ms'); + expect(milliseconds(1, undefined, { unitSeparator: ' ' })).toBe('1 ms'); + expect(milliseconds(100)).toBe('100ms'); expect(milliseconds(1000)).toBe('1,000ms'); expect(milliseconds(10_000)).toBe('10,000ms'); @@ -87,6 +90,7 @@ describe('unit_format', () => { it('decimalBytes', () => { expect(decimalBytes(1)).toBe('1B'); expect(decimalBytes(1, 1)).toBe('1.0B'); + expect(decimalBytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 B'); expect(decimalBytes(10)).toBe('10B'); expect(decimalBytes(10 ** 2)).toBe('100B'); @@ -104,31 +108,37 @@ describe('unit_format', () => { it('kilobytes', () => { expect(kilobytes(1)).toBe('1kB'); expect(kilobytes(1, 1)).toBe('1.0kB'); + expect(kilobytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 kB'); }); it('megabytes', () => { expect(megabytes(1)).toBe('1MB'); expect(megabytes(1, 1)).toBe('1.0MB'); + expect(megabytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 MB'); }); it('gigabytes', () => { expect(gigabytes(1)).toBe('1GB'); expect(gigabytes(1, 1)).toBe('1.0GB'); + expect(gigabytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 GB'); }); it('terabytes', () => { expect(terabytes(1)).toBe('1TB'); expect(terabytes(1, 1)).toBe('1.0TB'); + expect(terabytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 TB'); }); it('petabytes', () => { expect(petabytes(1)).toBe('1PB'); expect(petabytes(1, 1)).toBe('1.0PB'); + expect(petabytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 PB'); }); it('bytes', () => { expect(bytes(1)).toBe('1B'); expect(bytes(1, 1)).toBe('1.0B'); + expect(bytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 B'); expect(bytes(10)).toBe('10B'); expect(bytes(100)).toBe('100B'); @@ -142,26 +152,31 @@ describe('unit_format', () => { it('kibibytes', () => { expect(kibibytes(1)).toBe('1KiB'); expect(kibibytes(1, 1)).toBe('1.0KiB'); + expect(kibibytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 KiB'); }); it('mebibytes', () => { expect(mebibytes(1)).toBe('1MiB'); expect(mebibytes(1, 1)).toBe('1.0MiB'); + expect(mebibytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 MiB'); }); it('gibibytes', () => { expect(gibibytes(1)).toBe('1GiB'); expect(gibibytes(1, 1)).toBe('1.0GiB'); + expect(gibibytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 GiB'); }); it('tebibytes', () => { expect(tebibytes(1)).toBe('1TiB'); expect(tebibytes(1, 1)).toBe('1.0TiB'); + expect(tebibytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 TiB'); }); it('pebibytes', () => { expect(pebibytes(1)).toBe('1PiB'); expect(pebibytes(1, 1)).toBe('1.0PiB'); + expect(pebibytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 PiB'); }); describe('getFormatter', () => { diff --git a/spec/frontend/lib/utils/users_cache_spec.js b/spec/frontend/lib/utils/users_cache_spec.js index 4034f39ee9c..30bdddd8e73 100644 --- a/spec/frontend/lib/utils/users_cache_spec.js +++ b/spec/frontend/lib/utils/users_cache_spec.js @@ -93,7 +93,7 @@ describe('UsersCache', () => { .mockImplementation((query, options) => apiSpy(query, options)); }); - it('stores and returns data from API call if cache is empty', (done) => { + it('stores and returns data from API call if cache is empty', async () => { apiSpy = (query, options) => { expect(query).toBe(''); expect(options).toEqual({ @@ -105,16 +105,12 @@ describe('UsersCache', () => { }); }; - UsersCache.retrieve(dummyUsername) - .then((user) => { - expect(user).toBe(dummyUser); - expect(UsersCache.internalStorage[dummyUsername]).toBe(dummyUser); - }) - .then(done) - .catch(done.fail); + const user = await UsersCache.retrieve(dummyUsername); + expect(user).toBe(dummyUser); + expect(UsersCache.internalStorage[dummyUsername]).toBe(dummyUser); }); - it('returns undefined if Ajax call fails and cache is empty', (done) => { + it('returns undefined if Ajax call fails and cache is empty', async () => { const dummyError = new Error('server exploded'); apiSpy = (query, options) => { @@ -126,26 +122,18 @@ describe('UsersCache', () => { return Promise.reject(dummyError); }; - UsersCache.retrieve(dummyUsername) - .then((user) => done.fail(`Received unexpected user: ${JSON.stringify(user)}`)) - .catch((error) => { - expect(error).toBe(dummyError); - }) - .then(done) - .catch(done.fail); + await expect(UsersCache.retrieve(dummyUsername)).rejects.toEqual(dummyError); }); - it('makes no Ajax call if matching data exists', (done) => { + it('makes no Ajax call if matching data exists', async () => { UsersCache.internalStorage[dummyUsername] = dummyUser; - apiSpy = () => done.fail(new Error('expected no Ajax call!')); + apiSpy = () => { + throw new Error('expected no Ajax call!'); + }; - UsersCache.retrieve(dummyUsername) - .then((user) => { - expect(user).toBe(dummyUser); - }) - .then(done) - .catch(done.fail); + const user = await UsersCache.retrieve(dummyUsername); + expect(user).toBe(dummyUser); }); }); @@ -156,7 +144,7 @@ describe('UsersCache', () => { jest.spyOn(UserApi, 'getUser').mockImplementation((id) => apiSpy(id)); }); - it('stores and returns data from API call if cache is empty', (done) => { + it('stores and returns data from API call if cache is empty', async () => { apiSpy = (id) => { expect(id).toBe(dummyUserId); @@ -165,16 +153,12 @@ describe('UsersCache', () => { }); }; - UsersCache.retrieveById(dummyUserId) - .then((user) => { - expect(user).toBe(dummyUser); - expect(UsersCache.internalStorage[dummyUserId]).toBe(dummyUser); - }) - .then(done) - .catch(done.fail); + const user = await UsersCache.retrieveById(dummyUserId); + expect(user).toBe(dummyUser); + expect(UsersCache.internalStorage[dummyUserId]).toBe(dummyUser); }); - it('returns undefined if Ajax call fails and cache is empty', (done) => { + it('returns undefined if Ajax call fails and cache is empty', async () => { const dummyError = new Error('server exploded'); apiSpy = (id) => { @@ -183,26 +167,18 @@ describe('UsersCache', () => { return Promise.reject(dummyError); }; - UsersCache.retrieveById(dummyUserId) - .then((user) => done.fail(`Received unexpected user: ${JSON.stringify(user)}`)) - .catch((error) => { - expect(error).toBe(dummyError); - }) - .then(done) - .catch(done.fail); + await expect(UsersCache.retrieveById(dummyUserId)).rejects.toEqual(dummyError); }); - it('makes no Ajax call if matching data exists', (done) => { + it('makes no Ajax call if matching data exists', async () => { UsersCache.internalStorage[dummyUserId] = dummyUser; - apiSpy = () => done.fail(new Error('expected no Ajax call!')); + apiSpy = () => { + throw new Error('expected no Ajax call!'); + }; - UsersCache.retrieveById(dummyUserId) - .then((user) => { - expect(user).toBe(dummyUser); - }) - .then(done) - .catch(done.fail); + const user = await UsersCache.retrieveById(dummyUserId); + expect(user).toBe(dummyUser); }); }); @@ -213,7 +189,7 @@ describe('UsersCache', () => { jest.spyOn(UserApi, 'getUserStatus').mockImplementation((id) => apiSpy(id)); }); - it('stores and returns data from API call if cache is empty', (done) => { + it('stores and returns data from API call if cache is empty', async () => { apiSpy = (id) => { expect(id).toBe(dummyUserId); @@ -222,16 +198,12 @@ describe('UsersCache', () => { }); }; - UsersCache.retrieveStatusById(dummyUserId) - .then((userStatus) => { - expect(userStatus).toBe(dummyUserStatus); - expect(UsersCache.internalStorage[dummyUserId].status).toBe(dummyUserStatus); - }) - .then(done) - .catch(done.fail); + const userStatus = await UsersCache.retrieveStatusById(dummyUserId); + expect(userStatus).toBe(dummyUserStatus); + expect(UsersCache.internalStorage[dummyUserId].status).toBe(dummyUserStatus); }); - it('returns undefined if Ajax call fails and cache is empty', (done) => { + it('returns undefined if Ajax call fails and cache is empty', async () => { const dummyError = new Error('server exploded'); apiSpy = (id) => { @@ -240,28 +212,20 @@ describe('UsersCache', () => { return Promise.reject(dummyError); }; - UsersCache.retrieveStatusById(dummyUserId) - .then((userStatus) => done.fail(`Received unexpected user: ${JSON.stringify(userStatus)}`)) - .catch((error) => { - expect(error).toBe(dummyError); - }) - .then(done) - .catch(done.fail); + await expect(UsersCache.retrieveStatusById(dummyUserId)).rejects.toEqual(dummyError); }); - it('makes no Ajax call if matching data exists', (done) => { + it('makes no Ajax call if matching data exists', async () => { UsersCache.internalStorage[dummyUserId] = { status: dummyUserStatus, }; - apiSpy = () => done.fail(new Error('expected no Ajax call!')); + apiSpy = () => { + throw new Error('expected no Ajax call!'); + }; - UsersCache.retrieveStatusById(dummyUserId) - .then((userStatus) => { - expect(userStatus).toBe(dummyUserStatus); - }) - .then(done) - .catch(done.fail); + const userStatus = await UsersCache.retrieveStatusById(dummyUserId); + expect(userStatus).toBe(dummyUserStatus); }); }); }); diff --git a/spec/frontend/members/components/table/members_table_spec.js b/spec/frontend/members/components/table/members_table_spec.js index b2756e506eb..298a01e4f4d 100644 --- a/spec/frontend/members/components/table/members_table_spec.js +++ b/spec/frontend/members/components/table/members_table_spec.js @@ -10,6 +10,7 @@ import MemberAvatar from '~/members/components/table/member_avatar.vue'; import MemberSource from '~/members/components/table/member_source.vue'; import MembersTable from '~/members/components/table/members_table.vue'; import RoleDropdown from '~/members/components/table/role_dropdown.vue'; +import UserDate from '~/vue_shared/components/user_date.vue'; import { MEMBER_TYPES, MEMBER_STATE_CREATED, @@ -106,14 +107,16 @@ describe('MembersTable', () => { }; it.each` - field | label | member | expectedComponent - ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar} - ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource} - ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt} - ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt} - ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt} - ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown} - ${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker} + field | label | member | expectedComponent + ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar} + ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource} + ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt} + ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt} + ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt} + ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown} + ${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker} + ${'userCreatedAt'} | ${'Created on'} | ${memberMock} | ${UserDate} + ${'lastActivityOn'} | ${'Last activity'} | ${memberMock} | ${UserDate} `('renders the $label field', ({ field, label, member, expectedComponent }) => { createComponent({ members: [member], diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js index 83856a00a15..06ccd107ce3 100644 --- a/spec/frontend/members/mock_data.js +++ b/spec/frontend/members/mock_data.js @@ -17,6 +17,7 @@ export const member = { state: MEMBER_STATE_CREATED, user: { id: 123, + createdAt: '2022-03-10T18:03:04.812Z', name: 'Administrator', username: 'root', webUrl: 'https://gitlab.com/root', @@ -26,6 +27,7 @@ export const member = { oncallSchedules: [{ name: 'schedule 1' }], escalationPolicies: [{ name: 'policy 1' }], availability: null, + lastActivityOn: '2022-03-15', showStatus: true, }, id: 238, @@ -56,6 +58,7 @@ export const group = { webUrl: 'https://gitlab.com/groups/parent-group/commit451', }, id: 3, + isDirectMember: true, createdAt: '2020-08-06T15:31:07.662Z', expiresAt: null, validRoles: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }, diff --git a/spec/frontend/merge_conflicts/store/actions_spec.js b/spec/frontend/merge_conflicts/store/actions_spec.js index 3e1774a6d56..1b6a0f9e977 100644 --- a/spec/frontend/merge_conflicts/store/actions_spec.js +++ b/spec/frontend/merge_conflicts/store/actions_spec.js @@ -34,9 +34,9 @@ describe('merge conflicts actions', () => { describe('fetchConflictsData', () => { const conflictsPath = 'conflicts/path/mock'; - it('on success dispatches setConflictsData', (done) => { + it('on success dispatches setConflictsData', () => { mock.onGet(conflictsPath).reply(200, {}); - testAction( + return testAction( actions.fetchConflictsData, conflictsPath, {}, @@ -45,13 +45,12 @@ describe('merge conflicts actions', () => { { type: types.SET_LOADING_STATE, payload: false }, ], [{ type: 'setConflictsData', payload: {} }], - done, ); }); - it('when data has type equal to error ', (done) => { + it('when data has type equal to error ', () => { mock.onGet(conflictsPath).reply(200, { type: 'error', message: 'error message' }); - testAction( + return testAction( actions.fetchConflictsData, conflictsPath, {}, @@ -61,13 +60,12 @@ describe('merge conflicts actions', () => { { type: types.SET_LOADING_STATE, payload: false }, ], [], - done, ); }); - it('when request fails ', (done) => { + it('when request fails ', () => { mock.onGet(conflictsPath).reply(400); - testAction( + return testAction( actions.fetchConflictsData, conflictsPath, {}, @@ -77,15 +75,14 @@ describe('merge conflicts actions', () => { { type: types.SET_LOADING_STATE, payload: false }, ], [], - done, ); }); }); describe('setConflictsData', () => { - it('INTERACTIVE_RESOLVE_MODE updates the correct file ', (done) => { + it('INTERACTIVE_RESOLVE_MODE updates the correct file ', () => { decorateFiles.mockReturnValue([{ bar: 'baz' }]); - testAction( + return testAction( actions.setConflictsData, { files, foo: 'bar' }, {}, @@ -96,7 +93,6 @@ describe('merge conflicts actions', () => { }, ], [], - done, ); }); }); @@ -105,24 +101,21 @@ describe('merge conflicts actions', () => { useMockLocationHelper(); const resolveConflictsPath = 'resolve/conflicts/path/mock'; - it('on success reloads the page', (done) => { + it('on success reloads the page', async () => { mock.onPost(resolveConflictsPath).reply(200, { redirect_to: 'hrefPath' }); - testAction( + await testAction( actions.submitResolvedConflicts, resolveConflictsPath, {}, [{ type: types.SET_SUBMIT_STATE, payload: true }], [], - () => { - expect(window.location.assign).toHaveBeenCalledWith('hrefPath'); - done(); - }, ); + expect(window.location.assign).toHaveBeenCalledWith('hrefPath'); }); - it('on errors shows flash', (done) => { + it('on errors shows flash', async () => { mock.onPost(resolveConflictsPath).reply(400); - testAction( + await testAction( actions.submitResolvedConflicts, resolveConflictsPath, {}, @@ -131,13 +124,10 @@ describe('merge conflicts actions', () => { { type: types.SET_SUBMIT_STATE, payload: false }, ], [], - () => { - expect(createFlash).toHaveBeenCalledWith({ - message: 'Failed to save merge conflicts resolutions. Please try again!', - }); - done(); - }, ); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Failed to save merge conflicts resolutions. Please try again!', + }); }); }); @@ -193,9 +183,9 @@ describe('merge conflicts actions', () => { }); describe('setViewType', () => { - it('commits the right mutation', (done) => { + it('commits the right mutation', async () => { const payload = 'viewType'; - testAction( + await testAction( actions.setViewType, payload, {}, @@ -206,14 +196,11 @@ describe('merge conflicts actions', () => { }, ], [], - () => { - expect(Cookies.set).toHaveBeenCalledWith('diff_view', payload, { - expires: 365, - secure: false, - }); - done(); - }, ); + expect(Cookies.set).toHaveBeenCalledWith('diff_view', payload, { + expires: 365, + secure: false, + }); }); }); @@ -252,8 +239,8 @@ describe('merge conflicts actions', () => { }); describe('setFileResolveMode', () => { - it('INTERACTIVE_RESOLVE_MODE updates the correct file ', (done) => { - testAction( + it('INTERACTIVE_RESOLVE_MODE updates the correct file ', () => { + return testAction( actions.setFileResolveMode, { file: files[0], mode: INTERACTIVE_RESOLVE_MODE }, { conflictsData: { files }, getFileIndex: () => 0 }, @@ -267,11 +254,10 @@ describe('merge conflicts actions', () => { }, ], [], - done, ); }); - it('EDIT_RESOLVE_MODE updates the correct file ', (done) => { + it('EDIT_RESOLVE_MODE updates the correct file ', async () => { restoreFileLinesState.mockReturnValue([]); const file = { ...files[0], @@ -280,7 +266,7 @@ describe('merge conflicts actions', () => { resolutionData: {}, resolveMode: EDIT_RESOLVE_MODE, }; - testAction( + await testAction( actions.setFileResolveMode, { file: files[0], mode: EDIT_RESOLVE_MODE }, { conflictsData: { files }, getFileIndex: () => 0 }, @@ -294,17 +280,14 @@ describe('merge conflicts actions', () => { }, ], [], - () => { - expect(restoreFileLinesState).toHaveBeenCalledWith(file); - done(); - }, ); + expect(restoreFileLinesState).toHaveBeenCalledWith(file); }); }); describe('setPromptConfirmationState', () => { - it('updates the correct file ', (done) => { - testAction( + it('updates the correct file ', () => { + return testAction( actions.setPromptConfirmationState, { file: files[0], promptDiscardConfirmation: true }, { conflictsData: { files }, getFileIndex: () => 0 }, @@ -318,7 +301,6 @@ describe('merge conflicts actions', () => { }, ], [], - done, ); }); }); @@ -333,11 +315,11 @@ describe('merge conflicts actions', () => { ], }; - it('updates the correct file ', (done) => { + it('updates the correct file ', async () => { const marLikeMockReturn = { foo: 'bar' }; markLine.mockReturnValue(marLikeMockReturn); - testAction( + await testAction( actions.handleSelected, { file, line: { id: 1, section: 'baz' } }, { conflictsData: { files }, getFileIndex: () => 0 }, @@ -359,11 +341,8 @@ describe('merge conflicts actions', () => { }, ], [], - () => { - expect(markLine).toHaveBeenCalledTimes(3); - done(); - }, ); + expect(markLine).toHaveBeenCalledTimes(3); }); }); }); diff --git a/spec/frontend/milestones/components/delete_milestone_modal_spec.js b/spec/frontend/milestones/components/delete_milestone_modal_spec.js index 8978de0e0e0..b9ba0833c4f 100644 --- a/spec/frontend/milestones/components/delete_milestone_modal_spec.js +++ b/spec/frontend/milestones/components/delete_milestone_modal_spec.js @@ -32,7 +32,7 @@ describe('delete_milestone_modal.vue', () => { jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); }); - it('deletes milestone and redirects to overview page', (done) => { + 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); @@ -48,19 +48,15 @@ describe('delete_milestone_modal.vue', () => { }); }); - vm.onSubmit() - .then(() => { - expect(redirectTo).toHaveBeenCalledWith(responseURL); - expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', { - milestoneUrl: props.milestoneUrl, - successful: true, - }); - }) - .then(done) - .catch(done.fail); + await vm.onSubmit(); + expect(redirectTo).toHaveBeenCalledWith(responseURL); + expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', { + milestoneUrl: props.milestoneUrl, + successful: true, + }); }); - it('displays error if deleting milestone failed', (done) => { + 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) => { @@ -73,17 +69,12 @@ describe('delete_milestone_modal.vue', () => { return Promise.reject(dummyError); }); - vm.onSubmit() - .catch((error) => { - expect(error).toBe(dummyError); - expect(redirectTo).not.toHaveBeenCalled(); - expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', { - milestoneUrl: props.milestoneUrl, - successful: false, - }); - }) - .then(done) - .catch(done.fail); + await expect(vm.onSubmit()).rejects.toEqual(dummyError); + expect(redirectTo).not.toHaveBeenCalled(); + expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', { + milestoneUrl: props.milestoneUrl, + successful: false, + }); }); }); diff --git a/spec/frontend/milestones/components/milestone_combobox_spec.js b/spec/frontend/milestones/components/milestone_combobox_spec.js index 1af39aff30c..afd85fb78ce 100644 --- a/spec/frontend/milestones/components/milestone_combobox_spec.js +++ b/spec/frontend/milestones/components/milestone_combobox_spec.js @@ -340,7 +340,9 @@ describe('Milestone combobox component', () => { await nextTick(); expect( - findFirstProjectMilestonesDropdownItem().find('span').classes('selected-item'), + findFirstProjectMilestonesDropdownItem() + .find('svg') + .classes('gl-new-dropdown-item-check-icon'), ).toBe(true); selectFirstProjectMilestone(); @@ -348,8 +350,8 @@ describe('Milestone combobox component', () => { await nextTick(); expect( - findFirstProjectMilestonesDropdownItem().find('span').classes('selected-item'), - ).toBe(false); + findFirstProjectMilestonesDropdownItem().find('svg').classes('gl-visibility-hidden'), + ).toBe(true); }); describe('when a project milestones is selected', () => { @@ -464,17 +466,19 @@ describe('Milestone combobox component', () => { await nextTick(); - expect(findFirstGroupMilestonesDropdownItem().find('span').classes('selected-item')).toBe( - true, - ); + expect( + findFirstGroupMilestonesDropdownItem() + .find('svg') + .classes('gl-new-dropdown-item-check-icon'), + ).toBe(true); selectFirstGroupMilestone(); await nextTick(); - expect(findFirstGroupMilestonesDropdownItem().find('span').classes('selected-item')).toBe( - false, - ); + expect( + findFirstGroupMilestonesDropdownItem().find('svg').classes('gl-visibility-hidden'), + ).toBe(true); }); describe('when a group milestones is selected', () => { 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 bd2e818df4f..28039321428 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -5,7 +5,7 @@ exports[`Dashboard template matches the default snapshot 1`] = ` class="prometheus-graphs" data-qa-selector="prometheus_graphs" environmentstate="available" - metricsdashboardbasepath="/monitoring/monitor-project/-/environments/1/metrics" + metricsdashboardbasepath="/monitoring/monitor-project/-/metrics?environment=1" metricsendpoint="/monitoring/monitor-project/-/environments/1/additional_metrics.json" > <div> @@ -17,11 +17,11 @@ exports[`Dashboard template matches the default snapshot 1`] = ` primarybuttontext="" secondarybuttonlink="" secondarybuttontext="" - title="Feature deprecation and removal" - variant="danger" + title="Feature deprecation" + variant="warning" > <gl-sprintf-stub - message="The metrics, logs and tracing features were deprecated in GitLab 14.7 and are %{epicStart} scheduled for removal %{epicEnd} in GitLab 15.0." + message="The metrics feature was deprecated in GitLab 14.7." /> <gl-sprintf-stub diff --git a/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap index 4f8a82692b8..08487a7a796 100644 --- a/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap @@ -6,6 +6,7 @@ exports[`EmptyState shows gettingStarted state 1`] = ` <gl-empty-state-stub description="Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments." + invertindarkmode="true" primarybuttonlink="/clustersPath" primarybuttontext="Install on clusters" secondarybuttonlink="/settingsPath" @@ -22,6 +23,7 @@ exports[`EmptyState shows noData state 1`] = ` <gl-empty-state-stub description="You are connected to the Prometheus server, but there is currently no data to display." + invertindarkmode="true" primarybuttonlink="/settingsPath" primarybuttontext="Configure Prometheus" secondarybuttonlink="" @@ -38,6 +40,7 @@ exports[`EmptyState shows unableToConnect state 1`] = ` <gl-empty-state-stub description="Ensure connectivity is available from the GitLab server to the Prometheus server" + invertindarkmode="true" primarybuttonlink="/documentationPath" primarybuttontext="View documentation" secondarybuttonlink="/settingsPath" diff --git a/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap index 9b2aa3a5b5b..1d7ff420a17 100644 --- a/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap @@ -4,6 +4,7 @@ exports[`GroupEmptyState given state BAD_QUERY passes the expected props to GlEm Object { "compact": true, "description": null, + "invertInDarkMode": true, "primaryButtonLink": "/path/to/settings", "primaryButtonText": "Verify configuration", "secondaryButtonLink": null, @@ -31,6 +32,7 @@ exports[`GroupEmptyState given state CONNECTION_FAILED passes the expected props Object { "compact": true, "description": "We couldn't reach the Prometheus server. Either the server no longer exists or the configuration details need updating.", + "invertInDarkMode": true, "primaryButtonLink": "/path/to/settings", "primaryButtonText": "Verify configuration", "secondaryButtonLink": null, @@ -47,6 +49,7 @@ exports[`GroupEmptyState given state FOO STATE passes the expected props to GlEm Object { "compact": true, "description": "An error occurred while loading the data. Please try again.", + "invertInDarkMode": true, "primaryButtonLink": null, "primaryButtonText": null, "secondaryButtonLink": null, @@ -63,6 +66,7 @@ exports[`GroupEmptyState given state LOADING passes the expected props to GlEmpt Object { "compact": true, "description": "Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available.", + "invertInDarkMode": true, "primaryButtonLink": null, "primaryButtonText": null, "secondaryButtonLink": null, @@ -79,6 +83,7 @@ exports[`GroupEmptyState given state NO_DATA passes the expected props to GlEmpt Object { "compact": true, "description": null, + "invertInDarkMode": true, "primaryButtonLink": null, "primaryButtonText": null, "secondaryButtonLink": null, @@ -106,6 +111,7 @@ exports[`GroupEmptyState given state TIMEOUT passes the expected props to GlEmpt Object { "compact": true, "description": null, + "invertInDarkMode": true, "primaryButtonLink": null, "primaryButtonText": null, "secondaryButtonLink": null, @@ -133,6 +139,7 @@ exports[`GroupEmptyState given state UNKNOWN_ERROR passes the expected props to Object { "compact": true, "description": "An error occurred while loading the data. Please try again.", + "invertInDarkMode": true, "primaryButtonLink": null, "primaryButtonText": null, "secondaryButtonLink": null, diff --git a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js index d0d0c3071d5..d74f959ac0f 100644 --- a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js +++ b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js @@ -109,7 +109,7 @@ describe('Actions menu', () => { describe('adding new metric from modal', () => { let origPage; - beforeEach((done) => { + beforeEach(() => { jest.spyOn(Tracking, 'event').mockReturnValue(); createShallowWrapper(); @@ -118,7 +118,7 @@ describe('Actions menu', () => { origPage = document.body.dataset.page; document.body.dataset.page = 'projects:environments:metrics'; - nextTick(done); + return nextTick(); }); afterEach(() => { diff --git a/spec/frontend/monitoring/components/dashboard_url_time_spec.js b/spec/frontend/monitoring/components/dashboard_url_time_spec.js index 246dd598d19..64c48100b31 100644 --- a/spec/frontend/monitoring/components/dashboard_url_time_spec.js +++ b/spec/frontend/monitoring/components/dashboard_url_time_spec.js @@ -126,7 +126,7 @@ describe('dashboard invalid url parameters', () => { }); it('redirects to different time range', async () => { - const toUrl = `${mockProjectDir}/-/environments/1/metrics`; + const toUrl = `${mockProjectDir}/-/metrics?environment=1`; removeParams.mockReturnValueOnce(toUrl); createMountedWrapper(); diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js index f60c531e3f6..d1a13fbf9cd 100644 --- a/spec/frontend/monitoring/store/actions_spec.js +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -7,9 +7,9 @@ import * as commonUtils from '~/lib/utils/common_utils'; import statusCodes from '~/lib/utils/http_status'; import { ENVIRONMENT_AVAILABLE_STATE } from '~/monitoring/constants'; -import getAnnotations from '~/monitoring/queries/getAnnotations.query.graphql'; -import getDashboardValidationWarnings from '~/monitoring/queries/getDashboardValidationWarnings.query.graphql'; -import getEnvironments from '~/monitoring/queries/getEnvironments.query.graphql'; +import getAnnotations from '~/monitoring/queries/get_annotations.query.graphql'; +import getDashboardValidationWarnings from '~/monitoring/queries/get_dashboard_validation_warnings.query.graphql'; +import getEnvironments from '~/monitoring/queries/get_environments.query.graphql'; import { createStore } from '~/monitoring/stores'; import { setGettingStartedEmptyState, @@ -88,8 +88,8 @@ describe('Monitoring store actions', () => { // Setup describe('setGettingStartedEmptyState', () => { - it('should commit SET_GETTING_STARTED_EMPTY_STATE mutation', (done) => { - testAction( + it('should commit SET_GETTING_STARTED_EMPTY_STATE mutation', () => { + return testAction( setGettingStartedEmptyState, null, state, @@ -99,14 +99,13 @@ describe('Monitoring store actions', () => { }, ], [], - done, ); }); }); describe('setInitialState', () => { - it('should commit SET_INITIAL_STATE mutation', (done) => { - testAction( + it('should commit SET_INITIAL_STATE mutation', () => { + return testAction( setInitialState, { currentDashboard: '.gitlab/dashboards/dashboard.yml', @@ -123,7 +122,6 @@ describe('Monitoring store actions', () => { }, ], [], - done, ); }); }); @@ -233,51 +231,39 @@ describe('Monitoring store actions', () => { }; }); - it('dispatches a failure', (done) => { - result() - .then(() => { - expect(commit).toHaveBeenCalledWith( - types.SET_ALL_DASHBOARDS, - mockDashboardsErrorResponse.all_dashboards, - ); - expect(dispatch).toHaveBeenCalledWith( - 'receiveMetricsDashboardFailure', - new Error('Request failed with status code 500'), - ); - expect(createFlash).toHaveBeenCalled(); - done(); - }) - .catch(done.fail); + it('dispatches a failure', async () => { + await result(); + expect(commit).toHaveBeenCalledWith( + types.SET_ALL_DASHBOARDS, + mockDashboardsErrorResponse.all_dashboards, + ); + expect(dispatch).toHaveBeenCalledWith( + 'receiveMetricsDashboardFailure', + new Error('Request failed with status code 500'), + ); + expect(createFlash).toHaveBeenCalled(); }); - it('dispatches a failure action when a message is returned', (done) => { - result() - .then(() => { - expect(dispatch).toHaveBeenCalledWith( - 'receiveMetricsDashboardFailure', - new Error('Request failed with status code 500'), - ); - expect(createFlash).toHaveBeenCalledWith({ - message: expect.stringContaining(mockDashboardsErrorResponse.message), - }); - done(); - }) - .catch(done.fail); + it('dispatches a failure action when a message is returned', async () => { + await result(); + expect(dispatch).toHaveBeenCalledWith( + 'receiveMetricsDashboardFailure', + new Error('Request failed with status code 500'), + ); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringContaining(mockDashboardsErrorResponse.message), + }); }); - it('does not show a flash error when showErrorBanner is disabled', (done) => { + it('does not show a flash error when showErrorBanner is disabled', async () => { state.showErrorBanner = false; - result() - .then(() => { - expect(dispatch).toHaveBeenCalledWith( - 'receiveMetricsDashboardFailure', - new Error('Request failed with status code 500'), - ); - expect(createFlash).not.toHaveBeenCalled(); - done(); - }) - .catch(done.fail); + await result(); + expect(dispatch).toHaveBeenCalledWith( + 'receiveMetricsDashboardFailure', + new Error('Request failed with status code 500'), + ); + expect(createFlash).not.toHaveBeenCalled(); }); }); }); @@ -322,38 +308,30 @@ describe('Monitoring store actions', () => { state.timeRange = defaultTimeRange; }); - it('commits empty state when state.groups is empty', (done) => { + it('commits empty state when state.groups is empty', async () => { const localGetters = { metricsWithData: () => [], }; - fetchDashboardData({ state, commit, dispatch, getters: localGetters }) - .then(() => { - expect(Tracking.event).toHaveBeenCalledWith( - document.body.dataset.page, - 'dashboard_fetch', - { - label: 'custom_metrics_dashboard', - property: 'count', - value: 0, - }, - ); - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData'); - expect(dispatch).toHaveBeenCalledWith('fetchVariableMetricLabelValues', { - defaultQueryParams: { - start_time: expect.any(String), - end_time: expect.any(String), - step: expect.any(Number), - }, - }); + await fetchDashboardData({ state, commit, dispatch, getters: localGetters }); + expect(Tracking.event).toHaveBeenCalledWith(document.body.dataset.page, 'dashboard_fetch', { + label: 'custom_metrics_dashboard', + property: 'count', + value: 0, + }); + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData'); + expect(dispatch).toHaveBeenCalledWith('fetchVariableMetricLabelValues', { + defaultQueryParams: { + start_time: expect.any(String), + end_time: expect.any(String), + step: expect.any(Number), + }, + }); - expect(createFlash).not.toHaveBeenCalled(); - done(); - }) - .catch(done.fail); + expect(createFlash).not.toHaveBeenCalled(); }); - it('dispatches fetchPrometheusMetric for each panel query', (done) => { + it('dispatches fetchPrometheusMetric for each panel query', async () => { state.dashboard.panelGroups = convertObjectPropsToCamelCase( metricsDashboardResponse.dashboard.panel_groups, ); @@ -363,34 +341,24 @@ describe('Monitoring store actions', () => { metricsWithData: () => [metric.id], }; - fetchDashboardData({ state, commit, dispatch, getters: localGetters }) - .then(() => { - expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', { - metric, - defaultQueryParams: { - start_time: expect.any(String), - end_time: expect.any(String), - step: expect.any(Number), - }, - }); - - expect(Tracking.event).toHaveBeenCalledWith( - document.body.dataset.page, - 'dashboard_fetch', - { - label: 'custom_metrics_dashboard', - property: 'count', - value: 1, - }, - ); + await fetchDashboardData({ state, commit, dispatch, getters: localGetters }); + expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', { + metric, + defaultQueryParams: { + start_time: expect.any(String), + end_time: expect.any(String), + step: expect.any(Number), + }, + }); - done(); - }) - .catch(done.fail); - done(); + expect(Tracking.event).toHaveBeenCalledWith(document.body.dataset.page, 'dashboard_fetch', { + label: 'custom_metrics_dashboard', + property: 'count', + value: 1, + }); }); - it('dispatches fetchPrometheusMetric for each panel query, handles an error', (done) => { + it('dispatches fetchPrometheusMetric for each panel query, handles an error', async () => { state.dashboard.panelGroups = metricsDashboardViewModel.panelGroups; const metric = state.dashboard.panelGroups[0].panels[0].metrics[0]; @@ -400,30 +368,24 @@ describe('Monitoring store actions', () => { dispatch.mockRejectedValueOnce(new Error('Error fetching this metric')); dispatch.mockResolvedValue(); - fetchDashboardData({ state, commit, dispatch }) - .then(() => { - const defaultQueryParams = { - start_time: expect.any(String), - end_time: expect.any(String), - step: expect.any(Number), - }; - - expect(dispatch).toHaveBeenCalledTimes(metricsDashboardPanelCount + 2); // plus 1 for deployments - expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData'); - expect(dispatch).toHaveBeenCalledWith('fetchVariableMetricLabelValues', { - defaultQueryParams, - }); - expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', { - metric, - defaultQueryParams, - }); + await fetchDashboardData({ state, commit, dispatch }); + const defaultQueryParams = { + start_time: expect.any(String), + end_time: expect.any(String), + step: expect.any(Number), + }; - expect(createFlash).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledTimes(metricsDashboardPanelCount + 2); // plus 1 for deployments + expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData'); + expect(dispatch).toHaveBeenCalledWith('fetchVariableMetricLabelValues', { + defaultQueryParams, + }); + expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', { + metric, + defaultQueryParams, + }); - done(); - }) - .catch(done.fail); - done(); + expect(createFlash).toHaveBeenCalledTimes(1); }); }); @@ -449,10 +411,10 @@ describe('Monitoring store actions', () => { }; }); - it('commits result', (done) => { + it('commits result', () => { mock.onGet(prometheusEndpointPath).reply(200, { data }); // One attempt - testAction( + return testAction( fetchPrometheusMetric, { metric, defaultQueryParams }, state, @@ -472,10 +434,7 @@ describe('Monitoring store actions', () => { }, ], [], - () => { - done(); - }, - ).catch(done.fail); + ); }); describe('without metric defined step', () => { @@ -485,10 +444,10 @@ describe('Monitoring store actions', () => { step: 60, }; - it('uses calculated step', (done) => { + it('uses calculated step', async () => { mock.onGet(prometheusEndpointPath).reply(200, { data }); // One attempt - testAction( + await testAction( fetchPrometheusMetric, { metric, defaultQueryParams }, state, @@ -508,11 +467,8 @@ describe('Monitoring store actions', () => { }, ], [], - () => { - expect(mock.history.get[0].params).toEqual(expectedParams); - done(); - }, - ).catch(done.fail); + ); + expect(mock.history.get[0].params).toEqual(expectedParams); }); }); @@ -527,10 +483,10 @@ describe('Monitoring store actions', () => { step: 7, }; - it('uses metric step', (done) => { + it('uses metric step', async () => { mock.onGet(prometheusEndpointPath).reply(200, { data }); // One attempt - testAction( + await testAction( fetchPrometheusMetric, { metric, defaultQueryParams }, state, @@ -550,43 +506,39 @@ describe('Monitoring store actions', () => { }, ], [], - () => { - expect(mock.history.get[0].params).toEqual(expectedParams); - done(); - }, - ).catch(done.fail); + ); + expect(mock.history.get[0].params).toEqual(expectedParams); }); }); - it('commits failure, when waiting for results and getting a server error', (done) => { + it('commits failure, when waiting for results and getting a server error', async () => { mock.onGet(prometheusEndpointPath).reply(500); const error = new Error('Request failed with status code 500'); - testAction( - fetchPrometheusMetric, - { metric, defaultQueryParams }, - state, - [ - { - type: types.REQUEST_METRIC_RESULT, - payload: { - metricId: metric.metricId, + await expect( + testAction( + fetchPrometheusMetric, + { metric, defaultQueryParams }, + state, + [ + { + type: types.REQUEST_METRIC_RESULT, + payload: { + metricId: metric.metricId, + }, }, - }, - { - type: types.RECEIVE_METRIC_RESULT_FAILURE, - payload: { - metricId: metric.metricId, - error, + { + type: types.RECEIVE_METRIC_RESULT_FAILURE, + payload: { + metricId: metric.metricId, + error, + }, }, - }, - ], - [], - ).catch((e) => { - expect(e).toEqual(error); - done(); - }); + ], + [], + ), + ).rejects.toEqual(error); }); }); @@ -991,20 +943,16 @@ describe('Monitoring store actions', () => { state.dashboardsEndpoint = '/dashboards.json'; }); - it('Succesful POST request resolves', (done) => { + it('Succesful POST request resolves', async () => { mock.onPost(state.dashboardsEndpoint).reply(statusCodes.CREATED, { dashboard: dashboardGitResponse[1], }); - testAction(duplicateSystemDashboard, {}, state, [], []) - .then(() => { - expect(mock.history.post).toHaveLength(1); - done(); - }) - .catch(done.fail); + await testAction(duplicateSystemDashboard, {}, state, [], []); + expect(mock.history.post).toHaveLength(1); }); - it('Succesful POST request resolves to a dashboard', (done) => { + it('Succesful POST request resolves to a dashboard', async () => { const mockCreatedDashboard = dashboardGitResponse[1]; const params = { @@ -1025,50 +973,40 @@ describe('Monitoring store actions', () => { dashboard: mockCreatedDashboard, }); - testAction(duplicateSystemDashboard, params, state, [], []) - .then((result) => { - expect(mock.history.post).toHaveLength(1); - expect(mock.history.post[0].data).toEqual(expectedPayload); - expect(result).toEqual(mockCreatedDashboard); - - done(); - }) - .catch(done.fail); + const result = await testAction(duplicateSystemDashboard, params, state, [], []); + expect(mock.history.post).toHaveLength(1); + expect(mock.history.post[0].data).toEqual(expectedPayload); + expect(result).toEqual(mockCreatedDashboard); }); - it('Failed POST request throws an error', (done) => { + it('Failed POST request throws an error', async () => { mock.onPost(state.dashboardsEndpoint).reply(statusCodes.BAD_REQUEST); - testAction(duplicateSystemDashboard, {}, state, [], []).catch((err) => { - expect(mock.history.post).toHaveLength(1); - expect(err).toEqual(expect.any(String)); - - done(); - }); + await expect(testAction(duplicateSystemDashboard, {}, state, [], [])).rejects.toEqual( + 'There was an error creating the dashboard.', + ); + expect(mock.history.post).toHaveLength(1); }); - it('Failed POST request throws an error with a description', (done) => { + it('Failed POST request throws an error with a description', async () => { const backendErrorMsg = 'This file already exists!'; mock.onPost(state.dashboardsEndpoint).reply(statusCodes.BAD_REQUEST, { error: backendErrorMsg, }); - testAction(duplicateSystemDashboard, {}, state, [], []).catch((err) => { - expect(mock.history.post).toHaveLength(1); - expect(err).toEqual(expect.any(String)); - expect(err).toEqual(expect.stringContaining(backendErrorMsg)); - - done(); - }); + await expect(testAction(duplicateSystemDashboard, {}, state, [], [])).rejects.toEqual( + `There was an error creating the dashboard. ${backendErrorMsg}`, + ); + expect(mock.history.post).toHaveLength(1); }); }); // Variables manipulation describe('updateVariablesAndFetchData', () => { - it('should commit UPDATE_VARIABLE_VALUE mutation and fetch data', (done) => { - testAction( + it('should commit UPDATE_VARIABLE_VALUE mutation and fetch data', () => { + return testAction( updateVariablesAndFetchData, { pod: 'POD' }, state, @@ -1083,7 +1021,6 @@ describe('Monitoring store actions', () => { type: 'fetchDashboardData', }, ], - done, ); }); }); diff --git a/spec/frontend/monitoring/store/utils_spec.js b/spec/frontend/monitoring/store/utils_spec.js index 697bdb9185f..c25de8caa95 100644 --- a/spec/frontend/monitoring/store/utils_spec.js +++ b/spec/frontend/monitoring/store/utils_spec.js @@ -547,7 +547,7 @@ describe('parseEnvironmentsResponse', () => { { id: 1, name: 'env-1', - metrics_path: `${projectPath}/environments/1/metrics`, + metrics_path: `${projectPath}/-/metrics?environment=1`, }, ], }, @@ -562,7 +562,7 @@ describe('parseEnvironmentsResponse', () => { { id: 12, name: 'env-12', - metrics_path: `${projectPath}/environments/12/metrics`, + metrics_path: `${projectPath}/-/metrics?environment=12`, }, ], }, diff --git a/spec/frontend/mr_notes/stores/actions_spec.js b/spec/frontend/mr_notes/stores/actions_spec.js index c6578453d85..568c1b930c9 100644 --- a/spec/frontend/mr_notes/stores/actions_spec.js +++ b/spec/frontend/mr_notes/stores/actions_spec.js @@ -1,64 +1,37 @@ import MockAdapter from 'axios-mock-adapter'; - -import testAction from 'helpers/vuex_action_helper'; import axios from '~/lib/utils/axios_utils'; -import { setEndpoints, setMrMetadata, fetchMrMetadata } from '~/mr_notes/stores/actions'; -import mutationTypes from '~/mr_notes/stores/mutation_types'; +import { createStore } from '~/mr_notes/stores'; describe('MR Notes Mutator Actions', () => { + let store; + + beforeEach(() => { + store = createStore(); + }); + describe('setEndpoints', () => { - it('should trigger the SET_ENDPOINTS state mutation', (done) => { + it('sets endpoints', async () => { const endpoints = { endpointA: 'a' }; - testAction( - setEndpoints, - endpoints, - {}, - [ - { - type: mutationTypes.SET_ENDPOINTS, - payload: endpoints, - }, - ], - [], - done, - ); - }); - }); + await store.dispatch('setEndpoints', endpoints); - describe('setMrMetadata', () => { - it('should trigger the SET_MR_METADATA state mutation', async () => { - const mrMetadata = { propA: 'a', propB: 'b' }; - - await testAction( - setMrMetadata, - mrMetadata, - {}, - [ - { - type: mutationTypes.SET_MR_METADATA, - payload: mrMetadata, - }, - ], - [], - ); + expect(store.state.page.endpoints).toEqual(endpoints); }); }); describe('fetchMrMetadata', () => { const mrMetadata = { meta: true, data: 'foo' }; - const state = { - endpoints: { - metadata: 'metadata', - }, - }; + const metadata = 'metadata'; + const endpoints = { metadata }; let mock; - beforeEach(() => { + beforeEach(async () => { + await store.dispatch('setEndpoints', endpoints); + mock = new MockAdapter(axios); - mock.onGet(state.endpoints.metadata).reply(200, mrMetadata); + mock.onGet(metadata).reply(200, mrMetadata); }); afterEach(() => { @@ -66,27 +39,26 @@ describe('MR Notes Mutator Actions', () => { }); it('should fetch the data from the API', async () => { - await fetchMrMetadata({ state, dispatch: () => {} }); + await store.dispatch('fetchMrMetadata'); await axios.waitForAll(); expect(mock.history.get).toHaveLength(1); - expect(mock.history.get[0].url).toBe(state.endpoints.metadata); + expect(mock.history.get[0].url).toBe(metadata); + }); + + it('should set the fetched data into state', async () => { + await store.dispatch('fetchMrMetadata'); + + expect(store.state.page.mrMetadata).toEqual(mrMetadata); }); - it('should set the fetched data into state', () => { - return testAction( - fetchMrMetadata, - {}, - state, - [], - [ - { - type: 'setMrMetadata', - payload: mrMetadata, - }, - ], - ); + it('should set failedToLoadMetadata flag when request fails', async () => { + mock.onGet(metadata).reply(500); + + await store.dispatch('fetchMrMetadata'); + + expect(store.state.page.failedToLoadMetadata).toBe(true); }); }); }); diff --git a/spec/frontend/notes/components/diff_discussion_header_spec.js b/spec/frontend/notes/components/diff_discussion_header_spec.js index 9f94dd693cb..7878737fd31 100644 --- a/spec/frontend/notes/components/diff_discussion_header_spec.js +++ b/spec/frontend/notes/components/diff_discussion_header_spec.js @@ -4,7 +4,7 @@ import { nextTick } from 'vue'; import diffDiscussionHeader from '~/notes/components/diff_discussion_header.vue'; import createStore from '~/notes/stores'; -import mockDiffFile from '../../diffs/mock_data/diff_discussions'; +import mockDiffFile from 'jest/diffs/mock_data/diff_discussions'; import { discussionMock } from '../mock_data'; describe('diff_discussion_header component', () => { diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js index 780f24b3aa8..bf5a6b4966a 100644 --- a/spec/frontend/notes/components/note_actions_spec.js +++ b/spec/frontend/notes/components/note_actions_spec.js @@ -87,8 +87,7 @@ describe('noteActions', () => { }); it('should render emoji link', () => { - expect(wrapper.find('.js-add-award').exists()).toBe(true); - expect(wrapper.find('.js-add-award').attributes('data-position')).toBe('right'); + expect(wrapper.find('[data-testid="note-emoji-button"]').exists()).toBe(true); }); describe('actions dropdown', () => { diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js index 3e80b24f128..b709141f4ac 100644 --- a/spec/frontend/notes/components/note_form_spec.js +++ b/spec/frontend/notes/components/note_form_spec.js @@ -81,7 +81,6 @@ describe('issue_note_form component', () => { it('should show conflict message if note changes outside the component', async () => { wrapper.setProps({ ...props, - isEditing: true, noteBody: 'Foo', }); @@ -111,6 +110,12 @@ describe('issue_note_form component', () => { ); }); + it('should set data-supports-quick-actions to enable autocomplete', () => { + const textarea = wrapper.find('textarea'); + + expect(textarea.attributes('data-supports-quick-actions')).toBe('true'); + }); + it('should link to markdown docs', () => { const { markdownDocsPath } = notesDataMock; const markdownField = wrapper.find(MarkdownField); @@ -171,7 +176,6 @@ describe('issue_note_form component', () => { it('should be possible to cancel', async () => { wrapper.setProps({ ...props, - isEditing: true, }); await nextTick(); @@ -185,7 +189,6 @@ describe('issue_note_form component', () => { it('should be possible to update the note', async () => { wrapper.setProps({ ...props, - isEditing: true, }); await nextTick(); diff --git a/spec/frontend/notes/components/note_header_spec.js b/spec/frontend/notes/components/note_header_spec.js index 4671d33219d..3513b562e0a 100644 --- a/spec/frontend/notes/components/note_header_spec.js +++ b/spec/frontend/notes/components/note_header_spec.js @@ -263,7 +263,7 @@ describe('NoteHeader component', () => { }); describe('when author username link is hovered', () => { - it('toggles hover specific CSS classes on author name link', (done) => { + it('toggles hover specific CSS classes on author name link', async () => { createComponent({ author }); const authorUsernameLink = wrapper.find({ ref: 'authorUsernameLink' }); @@ -271,19 +271,15 @@ describe('NoteHeader component', () => { authorUsernameLink.trigger('mouseenter'); - nextTick(() => { - expect(authorNameLink.classes()).toContain('hover'); - expect(authorNameLink.classes()).toContain('text-underline'); + await nextTick(); + expect(authorNameLink.classes()).toContain('hover'); + expect(authorNameLink.classes()).toContain('text-underline'); - authorUsernameLink.trigger('mouseleave'); + authorUsernameLink.trigger('mouseleave'); - nextTick(() => { - expect(authorNameLink.classes()).not.toContain('hover'); - expect(authorNameLink.classes()).not.toContain('text-underline'); - - done(); - }); - }); + await nextTick(); + expect(authorNameLink.classes()).not.toContain('hover'); + expect(authorNameLink.classes()).not.toContain('text-underline'); }); }); @@ -296,5 +292,13 @@ describe('NoteHeader component', () => { createComponent({ isConfidential: status }); expect(findConfidentialIndicator().exists()).toBe(status); }); + + it('shows confidential indicator tooltip for project context', () => { + createComponent({ isConfidential: true, noteableType: 'issue' }); + + expect(findConfidentialIndicator().attributes('title')).toBe( + 'This comment is confidential and only visible to project members', + ); + }); }); }); diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js index 727ef02dcbb..c46d3bbe5b2 100644 --- a/spec/frontend/notes/components/noteable_discussion_spec.js +++ b/spec/frontend/notes/components/noteable_discussion_spec.js @@ -86,7 +86,6 @@ describe('noteable_discussion component', () => { const noteFormProps = noteForm.props(); expect(noteFormProps.discussion).toBe(discussionMock); - expect(noteFormProps.isEditing).toBe(false); expect(noteFormProps.line).toBe(null); expect(noteFormProps.saveButtonTitle).toBe('Comment'); expect(noteFormProps.autosaveKey).toBe(`Note/Issue/${discussionMock.id}/Reply`); diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js index c7115a5911b..385edc59eb6 100644 --- a/spec/frontend/notes/components/noteable_note_spec.js +++ b/spec/frontend/notes/components/noteable_note_spec.js @@ -11,6 +11,7 @@ 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'; @@ -226,6 +227,7 @@ describe('issue_note', () => { expect(noteHeaderProps.author).toBe(note.author); expect(noteHeaderProps.createdAt).toBe(note.created_at); expect(noteHeaderProps.noteId).toBe(note.id); + expect(noteHeaderProps.noteableType).toBe(NOTEABLE_TYPE_MAPPING[note.noteable_type]); }); it('should render note actions', () => { diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js index bf36d6cb7a2..e227af88d3f 100644 --- a/spec/frontend/notes/components/notes_app_spec.js +++ b/spec/frontend/notes/components/notes_app_spec.js @@ -300,16 +300,18 @@ describe('note_app', () => { await nextTick(); expect(wrapper.find(`.edit-note a[href="${markdownDocsPath}"]`).text().trim()).toEqual( - 'Markdown is supported', + 'Markdown', ); }); - it('should not render quick actions docs url', async () => { + it('should render quick actions docs url', async () => { wrapper.find('.js-note-edit').trigger('click'); const { quickActionsDocsPath } = mockData.notesDataMock; await nextTick(); - expect(wrapper.find(`.edit-note a[href="${quickActionsDocsPath}"]`).exists()).toBe(false); + expect(wrapper.find(`.edit-note a[href="${quickActionsDocsPath}"]`).text().trim()).toEqual( + 'quick actions', + ); }); }); diff --git a/spec/frontend/notes/components/sort_discussion_spec.js b/spec/frontend/notes/components/sort_discussion_spec.js index a279dfd1ef3..bde27b7e5fc 100644 --- a/spec/frontend/notes/components/sort_discussion_spec.js +++ b/spec/frontend/notes/components/sort_discussion_spec.js @@ -38,8 +38,8 @@ describe('Sort Discussion component', () => { createComponent(); }); - it('has local storage sync', () => { - expect(findLocalStorageSync().exists()).toBe(true); + it('has local storage sync with the correct props', () => { + expect(findLocalStorageSync().props('asString')).toBe(true); }); it('calls setDiscussionSortDirection when update is emitted', () => { diff --git a/spec/frontend/notes/deprecated_notes_spec.js b/spec/frontend/notes/deprecated_notes_spec.js index 7c52920da90..7193475c96a 100644 --- a/spec/frontend/notes/deprecated_notes_spec.js +++ b/spec/frontend/notes/deprecated_notes_spec.js @@ -561,7 +561,7 @@ describe.skip('Old Notes (~/deprecated_notes.js)', () => { }); describe('postComment', () => { - it('disables the submit button', (done) => { + it('disables the submit button', async () => { const $submitButton = $form.find('.js-comment-submit-button'); expect($submitButton).not.toBeDisabled(); @@ -574,13 +574,8 @@ describe.skip('Old Notes (~/deprecated_notes.js)', () => { return [200, note]; }); - notes - .postComment(dummyEvent) - .then(() => { - expect($submitButton).not.toBeDisabled(); - }) - .then(done) - .catch(done.fail); + await notes.postComment(dummyEvent); + expect($submitButton).not.toBeDisabled(); }); }); diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 7424a87bc0f..75e7756cd6b 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -62,118 +62,109 @@ describe('Actions Notes Store', () => { }); describe('setNotesData', () => { - it('should set received notes data', (done) => { - testAction( + it('should set received notes data', () => { + return testAction( actions.setNotesData, notesDataMock, { notesData: {} }, [{ type: 'SET_NOTES_DATA', payload: notesDataMock }], [], - done, ); }); }); describe('setNoteableData', () => { - it('should set received issue data', (done) => { - testAction( + it('should set received issue data', () => { + return testAction( actions.setNoteableData, noteableDataMock, { noteableData: {} }, [{ type: 'SET_NOTEABLE_DATA', payload: noteableDataMock }], [], - done, ); }); }); describe('setUserData', () => { - it('should set received user data', (done) => { - testAction( + it('should set received user data', () => { + return testAction( actions.setUserData, userDataMock, { userData: {} }, [{ type: 'SET_USER_DATA', payload: userDataMock }], [], - done, ); }); }); describe('setLastFetchedAt', () => { - it('should set received timestamp', (done) => { - testAction( + it('should set received timestamp', () => { + return testAction( actions.setLastFetchedAt, 'timestamp', { lastFetchedAt: {} }, [{ type: 'SET_LAST_FETCHED_AT', payload: 'timestamp' }], [], - done, ); }); }); describe('setInitialNotes', () => { - it('should set initial notes', (done) => { - testAction( + it('should set initial notes', () => { + return testAction( actions.setInitialNotes, [individualNote], { notes: [] }, [{ type: 'ADD_OR_UPDATE_DISCUSSIONS', payload: [individualNote] }], [], - done, ); }); }); describe('setTargetNoteHash', () => { - it('should set target note hash', (done) => { - testAction( + it('should set target note hash', () => { + return testAction( actions.setTargetNoteHash, 'hash', { notes: [] }, [{ type: 'SET_TARGET_NOTE_HASH', payload: 'hash' }], [], - done, ); }); }); describe('toggleDiscussion', () => { - it('should toggle discussion', (done) => { - testAction( + it('should toggle discussion', () => { + return testAction( actions.toggleDiscussion, { discussionId: discussionMock.id }, { notes: [discussionMock] }, [{ type: 'TOGGLE_DISCUSSION', payload: { discussionId: discussionMock.id } }], [], - done, ); }); }); describe('expandDiscussion', () => { - it('should expand discussion', (done) => { - testAction( + it('should expand discussion', () => { + return testAction( actions.expandDiscussion, { discussionId: discussionMock.id }, { notes: [discussionMock] }, [{ type: 'EXPAND_DISCUSSION', payload: { discussionId: discussionMock.id } }], [{ type: 'diffs/renderFileForDiscussionId', payload: discussionMock.id }], - done, ); }); }); describe('collapseDiscussion', () => { - it('should commit collapse discussion', (done) => { - testAction( + it('should commit collapse discussion', () => { + return testAction( actions.collapseDiscussion, { discussionId: discussionMock.id }, { notes: [discussionMock] }, [{ type: 'COLLAPSE_DISCUSSION', payload: { discussionId: discussionMock.id } }], [], - done, ); }); }); @@ -184,28 +175,18 @@ describe('Actions Notes Store', () => { }); describe('closeMergeRequest', () => { - it('sets state as closed', (done) => { - store - .dispatch('closeIssuable', { notesData: { closeIssuePath: '' } }) - .then(() => { - expect(store.state.noteableData.state).toEqual('closed'); - expect(store.state.isToggleStateButtonLoading).toEqual(false); - done(); - }) - .catch(done.fail); + it('sets state as closed', async () => { + await store.dispatch('closeIssuable', { notesData: { closeIssuePath: '' } }); + expect(store.state.noteableData.state).toEqual('closed'); + expect(store.state.isToggleStateButtonLoading).toEqual(false); }); }); describe('reopenMergeRequest', () => { - it('sets state as reopened', (done) => { - store - .dispatch('reopenIssuable', { notesData: { reopenIssuePath: '' } }) - .then(() => { - expect(store.state.noteableData.state).toEqual('reopened'); - expect(store.state.isToggleStateButtonLoading).toEqual(false); - done(); - }) - .catch(done.fail); + it('sets state as reopened', async () => { + await store.dispatch('reopenIssuable', { notesData: { reopenIssuePath: '' } }); + expect(store.state.noteableData.state).toEqual('reopened'); + expect(store.state.isToggleStateButtonLoading).toEqual(false); }); }); }); @@ -222,42 +203,39 @@ describe('Actions Notes Store', () => { }); describe('toggleStateButtonLoading', () => { - it('should set loading as true', (done) => { - testAction( + it('should set loading as true', () => { + return testAction( actions.toggleStateButtonLoading, true, {}, [{ type: 'TOGGLE_STATE_BUTTON_LOADING', payload: true }], [], - done, ); }); - it('should set loading as false', (done) => { - testAction( + it('should set loading as false', () => { + return testAction( actions.toggleStateButtonLoading, false, {}, [{ type: 'TOGGLE_STATE_BUTTON_LOADING', payload: false }], [], - done, ); }); }); describe('toggleIssueLocalState', () => { - it('sets issue state as closed', (done) => { - testAction(actions.toggleIssueLocalState, 'closed', {}, [{ type: 'CLOSE_ISSUE' }], [], done); + it('sets issue state as closed', () => { + return testAction(actions.toggleIssueLocalState, 'closed', {}, [{ type: 'CLOSE_ISSUE' }], []); }); - it('sets issue state as reopened', (done) => { - testAction( + it('sets issue state as reopened', () => { + return testAction( actions.toggleIssueLocalState, 'reopened', {}, [{ type: 'REOPEN_ISSUE' }], [], - done, ); }); }); @@ -291,8 +269,8 @@ describe('Actions Notes Store', () => { return store.dispatch('stopPolling'); }; - beforeEach((done) => { - store.dispatch('setNotesData', notesDataMock).then(done).catch(done.fail); + beforeEach(() => { + return store.dispatch('setNotesData', notesDataMock); }); afterEach(() => { @@ -405,14 +383,13 @@ describe('Actions Notes Store', () => { }); describe('setNotesFetchedState', () => { - it('should set notes fetched state', (done) => { - testAction( + it('should set notes fetched state', () => { + return testAction( actions.setNotesFetchedState, true, {}, [{ type: 'SET_NOTES_FETCHED_STATE', payload: true }], [], - done, ); }); }); @@ -432,10 +409,10 @@ describe('Actions Notes Store', () => { document.body.setAttribute('data-page', ''); }); - it('commits DELETE_NOTE and dispatches updateMergeRequestWidget', (done) => { + it('commits DELETE_NOTE and dispatches updateMergeRequestWidget', () => { const note = { path: endpoint, id: 1 }; - testAction( + return testAction( actions.removeNote, note, store.state, @@ -453,16 +430,15 @@ describe('Actions Notes Store', () => { type: 'updateResolvableDiscussionsCounts', }, ], - done, ); }); - it('dispatches removeDiscussionsFromDiff on merge request page', (done) => { + it('dispatches removeDiscussionsFromDiff on merge request page', () => { const note = { path: endpoint, id: 1 }; document.body.setAttribute('data-page', 'projects:merge_requests:show'); - testAction( + return testAction( actions.removeNote, note, store.state, @@ -483,7 +459,6 @@ describe('Actions Notes Store', () => { type: 'diffs/removeDiscussionsFromDiff', }, ], - done, ); }); }); @@ -503,10 +478,10 @@ describe('Actions Notes Store', () => { document.body.setAttribute('data-page', ''); }); - it('dispatches removeNote', (done) => { + it('dispatches removeNote', () => { const note = { path: endpoint, id: 1 }; - testAction( + return testAction( actions.deleteNote, note, {}, @@ -520,7 +495,6 @@ describe('Actions Notes Store', () => { }, }, ], - done, ); }); }); @@ -536,8 +510,8 @@ describe('Actions Notes Store', () => { axiosMock.onAny().reply(200, res); }); - it('commits ADD_NEW_NOTE and dispatches updateMergeRequestWidget', (done) => { - testAction( + it('commits ADD_NEW_NOTE and dispatches updateMergeRequestWidget', () => { + return testAction( actions.createNewNote, { endpoint: `${TEST_HOST}`, data: {} }, store.state, @@ -558,7 +532,6 @@ describe('Actions Notes Store', () => { type: 'updateResolvableDiscussionsCounts', }, ], - done, ); }); }); @@ -572,14 +545,13 @@ describe('Actions Notes Store', () => { axiosMock.onAny().replyOnce(200, res); }); - it('does not commit ADD_NEW_NOTE or dispatch updateMergeRequestWidget', (done) => { - testAction( + it('does not commit ADD_NEW_NOTE or dispatch updateMergeRequestWidget', () => { + return testAction( actions.createNewNote, { endpoint: `${TEST_HOST}`, data: {} }, store.state, [], [], - done, ); }); }); @@ -595,8 +567,8 @@ describe('Actions Notes Store', () => { }); describe('as note', () => { - it('commits UPDATE_NOTE and dispatches updateMergeRequestWidget', (done) => { - testAction( + it('commits UPDATE_NOTE and dispatches updateMergeRequestWidget', () => { + return testAction( actions.toggleResolveNote, { endpoint: `${TEST_HOST}`, isResolved: true, discussion: false }, store.state, @@ -614,14 +586,13 @@ describe('Actions Notes Store', () => { type: 'updateMergeRequestWidget', }, ], - done, ); }); }); describe('as discussion', () => { - it('commits UPDATE_DISCUSSION and dispatches updateMergeRequestWidget', (done) => { - testAction( + it('commits UPDATE_DISCUSSION and dispatches updateMergeRequestWidget', () => { + return testAction( actions.toggleResolveNote, { endpoint: `${TEST_HOST}`, isResolved: true, discussion: true }, store.state, @@ -639,7 +610,6 @@ describe('Actions Notes Store', () => { type: 'updateMergeRequestWidget', }, ], - done, ); }); }); @@ -656,41 +626,38 @@ describe('Actions Notes Store', () => { }); describe('setCommentsDisabled', () => { - it('should set comments disabled state', (done) => { - testAction( + it('should set comments disabled state', () => { + return testAction( actions.setCommentsDisabled, true, null, [{ type: 'DISABLE_COMMENTS', payload: true }], [], - done, ); }); }); describe('updateResolvableDiscussionsCounts', () => { - it('commits UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS', (done) => { - testAction( + it('commits UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS', () => { + return testAction( actions.updateResolvableDiscussionsCounts, null, {}, [{ type: 'UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS' }], [], - done, ); }); }); describe('convertToDiscussion', () => { - it('commits CONVERT_TO_DISCUSSION with noteId', (done) => { + it('commits CONVERT_TO_DISCUSSION with noteId', () => { const noteId = 'dummy-note-id'; - testAction( + return testAction( actions.convertToDiscussion, noteId, {}, [{ type: 'CONVERT_TO_DISCUSSION', payload: noteId }], [], - done, ); }); }); @@ -786,11 +753,11 @@ describe('Actions Notes Store', () => { describe('replyToDiscussion', () => { const payload = { endpoint: TEST_HOST, data: {} }; - it('updates discussion if response contains disussion', (done) => { + it('updates discussion if response contains disussion', () => { const discussion = { notes: [] }; axiosMock.onAny().reply(200, { discussion }); - testAction( + return testAction( actions.replyToDiscussion, payload, { @@ -802,15 +769,14 @@ describe('Actions Notes Store', () => { { type: 'startTaskList' }, { type: 'updateResolvableDiscussionsCounts' }, ], - done, ); }); - it('adds a reply to a discussion', (done) => { + it('adds a reply to a discussion', () => { const res = {}; axiosMock.onAny().reply(200, res); - testAction( + return testAction( actions.replyToDiscussion, payload, { @@ -818,21 +784,19 @@ describe('Actions Notes Store', () => { }, [{ type: mutationTypes.ADD_NEW_REPLY_TO_DISCUSSION, payload: res }], [], - done, ); }); }); describe('removeConvertedDiscussion', () => { - it('commits CONVERT_TO_DISCUSSION with noteId', (done) => { + it('commits CONVERT_TO_DISCUSSION with noteId', () => { const noteId = 'dummy-id'; - testAction( + return testAction( actions.removeConvertedDiscussion, noteId, {}, [{ type: 'REMOVE_CONVERTED_DISCUSSION', payload: noteId }], [], - done, ); }); }); @@ -849,8 +813,8 @@ describe('Actions Notes Store', () => { }; }); - it('when unresolved, dispatches action', (done) => { - testAction( + it('when unresolved, dispatches action', () => { + return testAction( actions.resolveDiscussion, { discussionId }, { ...state, ...getters }, @@ -865,20 +829,18 @@ describe('Actions Notes Store', () => { }, }, ], - done, ); }); - it('when resolved, does nothing', (done) => { + it('when resolved, does nothing', () => { getters.isDiscussionResolved = (id) => id === discussionId; - testAction( + return testAction( actions.resolveDiscussion, { discussionId }, { ...state, ...getters }, [], [], - done, ); }); }); @@ -891,22 +853,17 @@ describe('Actions Notes Store', () => { const res = { errors: { something: ['went wrong'] } }; const error = { message: 'Unprocessable entity', response: { data: res } }; - it('throws an error', (done) => { - actions - .saveNote( + it('throws an error', async () => { + await expect( + actions.saveNote( { commit() {}, dispatch: () => Promise.reject(error), }, payload, - ) - .then(() => done.fail('Expected error to be thrown!')) - .catch((err) => { - expect(err).toBe(error); - expect(createFlash).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + ), + ).rejects.toEqual(error); + expect(createFlash).not.toHaveBeenCalled(); }); }); @@ -914,46 +871,35 @@ describe('Actions Notes Store', () => { const res = { errors: { base: ['something went wrong'] } }; const error = { message: 'Unprocessable entity', response: { data: res } }; - it('sets flash alert using errors.base message', (done) => { - actions - .saveNote( - { - commit() {}, - dispatch: () => Promise.reject(error), - }, - { ...payload, flashContainer }, - ) - .then((resp) => { - expect(resp.hasFlash).toBe(true); - expect(createFlash).toHaveBeenCalledWith({ - message: 'Your comment could not be submitted because something went wrong', - parent: flashContainer, - }); - }) - .catch(() => done.fail('Expected success response!')) - .then(done) - .catch(done.fail); + it('sets flash alert using errors.base message', async () => { + const resp = await actions.saveNote( + { + commit() {}, + dispatch: () => Promise.reject(error), + }, + { ...payload, flashContainer }, + ); + expect(resp.hasFlash).toBe(true); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Your comment could not be submitted because something went wrong', + parent: flashContainer, + }); }); }); describe('if response contains no errors', () => { const res = { valid: true }; - it('returns the response', (done) => { - actions - .saveNote( - { - commit() {}, - dispatch: () => Promise.resolve(res), - }, - payload, - ) - .then((data) => { - expect(data).toBe(res); - expect(createFlash).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + it('returns the response', async () => { + const data = await actions.saveNote( + { + commit() {}, + dispatch: () => Promise.resolve(res), + }, + payload, + ); + expect(data).toBe(res); + expect(createFlash).not.toHaveBeenCalled(); }); }); }); @@ -970,19 +916,17 @@ describe('Actions Notes Store', () => { flashContainer = {}; }); - const testSubmitSuggestion = (done, expectFn) => { - actions - .submitSuggestion( - { commit, dispatch }, - { discussionId, noteId, suggestionId, flashContainer }, - ) - .then(expectFn) - .then(done) - .catch(done.fail); + const testSubmitSuggestion = async (expectFn) => { + await actions.submitSuggestion( + { commit, dispatch }, + { discussionId, noteId, suggestionId, flashContainer }, + ); + + expectFn(); }; - it('when service success, commits and resolves discussion', (done) => { - testSubmitSuggestion(done, () => { + it('when service success, commits and resolves discussion', () => { + testSubmitSuggestion(() => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_RESOLVING_DISCUSSION, true], [mutationTypes.SET_RESOLVING_DISCUSSION, false], @@ -997,12 +941,12 @@ describe('Actions Notes Store', () => { }); }); - it('when service fails, flashes error message', (done) => { + it('when service fails, flashes error message', () => { const response = { response: { data: { message: TEST_ERROR_MESSAGE } } }; Api.applySuggestion.mockReturnValue(Promise.reject(response)); - testSubmitSuggestion(done, () => { + return testSubmitSuggestion(() => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_RESOLVING_DISCUSSION, true], [mutationTypes.SET_RESOLVING_DISCUSSION, false], @@ -1015,12 +959,12 @@ describe('Actions Notes Store', () => { }); }); - it('when service fails, and no error message available, uses default message', (done) => { + it('when service fails, and no error message available, uses default message', () => { const response = { response: 'foo' }; Api.applySuggestion.mockReturnValue(Promise.reject(response)); - testSubmitSuggestion(done, () => { + return testSubmitSuggestion(() => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_RESOLVING_DISCUSSION, true], [mutationTypes.SET_RESOLVING_DISCUSSION, false], @@ -1033,10 +977,10 @@ describe('Actions Notes Store', () => { }); }); - it('when resolve discussion fails, fail gracefully', (done) => { + it('when resolve discussion fails, fail gracefully', () => { dispatch.mockReturnValue(Promise.reject()); - testSubmitSuggestion(done, () => { + return testSubmitSuggestion(() => { expect(createFlash).not.toHaveBeenCalled(); }); }); @@ -1056,16 +1000,14 @@ describe('Actions Notes Store', () => { flashContainer = {}; }); - const testSubmitSuggestionBatch = (done, expectFn) => { - actions - .submitSuggestionBatch({ commit, dispatch, state }, { flashContainer }) - .then(expectFn) - .then(done) - .catch(done.fail); + const testSubmitSuggestionBatch = async (expectFn) => { + await actions.submitSuggestionBatch({ commit, dispatch, state }, { flashContainer }); + + expectFn(); }; - it('when service succeeds, commits, resolves discussions, resets batch and applying batch state', (done) => { - testSubmitSuggestionBatch(done, () => { + it('when service succeeds, commits, resolves discussions, resets batch and applying batch state', () => { + testSubmitSuggestionBatch(() => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_APPLYING_BATCH_STATE, true], [mutationTypes.SET_RESOLVING_DISCUSSION, true], @@ -1085,12 +1027,12 @@ describe('Actions Notes Store', () => { }); }); - it('when service fails, flashes error message, resets applying batch state', (done) => { + it('when service fails, flashes error message, resets applying batch state', () => { const response = { response: { data: { message: TEST_ERROR_MESSAGE } } }; Api.applySuggestionBatch.mockReturnValue(Promise.reject(response)); - testSubmitSuggestionBatch(done, () => { + testSubmitSuggestionBatch(() => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_APPLYING_BATCH_STATE, true], [mutationTypes.SET_RESOLVING_DISCUSSION, true], @@ -1106,12 +1048,12 @@ describe('Actions Notes Store', () => { }); }); - it('when service fails, and no error message available, uses default message', (done) => { + it('when service fails, and no error message available, uses default message', () => { const response = { response: 'foo' }; Api.applySuggestionBatch.mockReturnValue(Promise.reject(response)); - testSubmitSuggestionBatch(done, () => { + testSubmitSuggestionBatch(() => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_APPLYING_BATCH_STATE, true], [mutationTypes.SET_RESOLVING_DISCUSSION, true], @@ -1128,10 +1070,10 @@ describe('Actions Notes Store', () => { }); }); - it('when resolve discussions fails, fails gracefully, resets batch and applying batch state', (done) => { + it('when resolve discussions fails, fails gracefully, resets batch and applying batch state', () => { dispatch.mockReturnValue(Promise.reject()); - testSubmitSuggestionBatch(done, () => { + testSubmitSuggestionBatch(() => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_APPLYING_BATCH_STATE, true], [mutationTypes.SET_RESOLVING_DISCUSSION, true], @@ -1148,14 +1090,13 @@ describe('Actions Notes Store', () => { describe('addSuggestionInfoToBatch', () => { const suggestionInfo = batchSuggestionsInfoMock[0]; - it("adds a suggestion's info to the current batch", (done) => { - testAction( + it("adds a suggestion's info to the current batch", () => { + return testAction( actions.addSuggestionInfoToBatch, suggestionInfo, { batchSuggestionsInfo: [] }, [{ type: 'ADD_SUGGESTION_TO_BATCH', payload: suggestionInfo }], [], - done, ); }); }); @@ -1163,14 +1104,13 @@ describe('Actions Notes Store', () => { describe('removeSuggestionInfoFromBatch', () => { const suggestionInfo = batchSuggestionsInfoMock[0]; - it("removes a suggestion's info the current batch", (done) => { - testAction( + it("removes a suggestion's info the current batch", () => { + return testAction( actions.removeSuggestionInfoFromBatch, suggestionInfo.suggestionId, { batchSuggestionsInfo: [suggestionInfo] }, [{ type: 'REMOVE_SUGGESTION_FROM_BATCH', payload: suggestionInfo.suggestionId }], [], - done, ); }); }); @@ -1209,8 +1149,8 @@ describe('Actions Notes Store', () => { }); describe('setDiscussionSortDirection', () => { - it('calls the correct mutation with the correct args', (done) => { - testAction( + it('calls the correct mutation with the correct args', () => { + return testAction( actions.setDiscussionSortDirection, { direction: notesConstants.DESC, persist: false }, {}, @@ -1221,20 +1161,18 @@ describe('Actions Notes Store', () => { }, ], [], - done, ); }); }); describe('setSelectedCommentPosition', () => { - it('calls the correct mutation with the correct args', (done) => { - testAction( + it('calls the correct mutation with the correct args', () => { + return testAction( actions.setSelectedCommentPosition, {}, {}, [{ type: mutationTypes.SET_SELECTED_COMMENT_POSITION, payload: {} }], [], - done, ); }); }); @@ -1248,9 +1186,9 @@ describe('Actions Notes Store', () => { }; describe('if response contains no errors', () => { - it('dispatches requestDeleteDescriptionVersion', (done) => { + it('dispatches requestDeleteDescriptionVersion', () => { axiosMock.onDelete(endpoint).replyOnce(200); - testAction( + return testAction( actions.softDeleteDescriptionVersion, payload, {}, @@ -1264,35 +1202,33 @@ describe('Actions Notes Store', () => { payload: payload.versionId, }, ], - done, ); }); }); describe('if response contains errors', () => { const errorMessage = 'Request failed with status code 503'; - it('dispatches receiveDeleteDescriptionVersionError and throws an error', (done) => { + it('dispatches receiveDeleteDescriptionVersionError and throws an error', async () => { axiosMock.onDelete(endpoint).replyOnce(503); - testAction( - actions.softDeleteDescriptionVersion, - payload, - {}, - [], - [ - { - type: 'requestDeleteDescriptionVersion', - }, - { - type: 'receiveDeleteDescriptionVersionError', - payload: new Error(errorMessage), - }, - ], - ) - .then(() => done.fail('Expected error to be thrown')) - .catch(() => { - expect(createFlash).toHaveBeenCalled(); - done(); - }); + await expect( + testAction( + actions.softDeleteDescriptionVersion, + payload, + {}, + [], + [ + { + type: 'requestDeleteDescriptionVersion', + }, + { + type: 'receiveDeleteDescriptionVersionError', + payload: new Error(errorMessage), + }, + ], + ), + ).rejects.toEqual(new Error()); + + expect(createFlash).toHaveBeenCalled(); }); }); }); @@ -1306,14 +1242,13 @@ describe('Actions Notes Store', () => { }); describe('updateAssignees', () => { - it('update the assignees state', (done) => { - testAction( + it('update the assignees state', () => { + return testAction( actions.updateAssignees, [userDataMock.id], { state: noteableDataMock }, [{ type: mutationTypes.UPDATE_ASSIGNEES, payload: [userDataMock.id] }], [], - done, ); }); }); @@ -1376,28 +1311,26 @@ describe('Actions Notes Store', () => { }); describe('updateDiscussionPosition', () => { - it('update the assignees state', (done) => { + it('update the assignees state', () => { const updatedPosition = { discussionId: 1, position: { test: true } }; - testAction( + return testAction( actions.updateDiscussionPosition, updatedPosition, { state: { discussions: [] } }, [{ type: mutationTypes.UPDATE_DISCUSSION_POSITION, payload: updatedPosition }], [], - done, ); }); }); describe('setFetchingState', () => { - it('commits SET_NOTES_FETCHING_STATE', (done) => { - testAction( + it('commits SET_NOTES_FETCHING_STATE', () => { + return testAction( actions.setFetchingState, true, null, [{ type: mutationTypes.SET_NOTES_FETCHING_STATE, payload: true }], [], - done, ); }); }); @@ -1409,9 +1342,9 @@ describe('Actions Notes Store', () => { window.gon = {}; }); - it('updates the discussions and dispatches `updateResolvableDiscussionsCounts`', (done) => { + it('updates the discussions and dispatches `updateResolvableDiscussionsCounts`', () => { axiosMock.onAny().reply(200, { discussion }); - testAction( + return testAction( actions.fetchDiscussions, {}, null, @@ -1420,14 +1353,13 @@ describe('Actions Notes Store', () => { { type: mutationTypes.SET_FETCHING_DISCUSSIONS, payload: false }, ], [{ type: 'updateResolvableDiscussionsCounts' }], - done, ); }); - it('dispatches `fetchDiscussionsBatch` action if `paginatedIssueDiscussions` feature flag is enabled', (done) => { + it('dispatches `fetchDiscussionsBatch` action if `paginatedIssueDiscussions` feature flag is enabled', () => { window.gon = { features: { paginatedIssueDiscussions: true } }; - testAction( + return testAction( actions.fetchDiscussions, { path: 'test-path', filter: 'test-filter', persistFilter: 'test-persist-filter' }, null, @@ -1444,7 +1376,6 @@ describe('Actions Notes Store', () => { }, }, ], - done, ); }); }); @@ -1458,9 +1389,9 @@ describe('Actions Notes Store', () => { const actionPayload = { config, path: 'test-path', perPage: 20 }; - it('updates the discussions and dispatches `updateResolvableDiscussionsCounts if there are no headers', (done) => { + it('updates the discussions and dispatches `updateResolvableDiscussionsCounts if there are no headers', () => { axiosMock.onAny().reply(200, { discussion }, {}); - testAction( + return testAction( actions.fetchDiscussionsBatch, actionPayload, null, @@ -1469,13 +1400,12 @@ describe('Actions Notes Store', () => { { type: mutationTypes.SET_FETCHING_DISCUSSIONS, payload: false }, ], [{ type: 'updateResolvableDiscussionsCounts' }], - done, ); }); - it('dispatches itself if there is `x-next-page-cursor` header', (done) => { + it('dispatches itself if there is `x-next-page-cursor` header', () => { axiosMock.onAny().reply(200, { discussion }, { 'x-next-page-cursor': 1 }); - testAction( + return testAction( actions.fetchDiscussionsBatch, actionPayload, null, @@ -1486,7 +1416,6 @@ describe('Actions Notes Store', () => { payload: { ...actionPayload, perPage: 30, cursor: 1 }, }, ], - done, ); }); }); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js index 6d7bf528495..ad67128502a 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js @@ -1,7 +1,7 @@ -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlTooltip, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import component from '~/packages_and_registries/container_registry/explorer/components/delete_button.vue'; +import { LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION } from '~/packages_and_registries/container_registry/explorer/constants/list'; describe('delete_button', () => { let wrapper; @@ -12,6 +12,7 @@ describe('delete_button', () => { }; const findButton = () => wrapper.find(GlButton); + const findTooltip = () => wrapper.find(GlTooltip); const mountComponent = (props) => { wrapper = shallowMount(component, { @@ -19,8 +20,9 @@ describe('delete_button', () => { ...defaultProps, ...props, }, - directives: { - GlTooltip: createMockDirective(), + stubs: { + GlTooltip, + GlSprintf, }, }); }; @@ -33,41 +35,50 @@ describe('delete_button', () => { describe('tooltip', () => { it('the title is controlled by tooltipTitle prop', () => { mountComponent(); - const tooltip = getBinding(wrapper.element, 'gl-tooltip'); + const tooltip = findTooltip(); expect(tooltip).toBeDefined(); - expect(tooltip.value.title).toBe(defaultProps.tooltipTitle); + expect(tooltip.text()).toBe(defaultProps.tooltipTitle); }); it('is disabled when tooltipTitle is disabled', () => { mountComponent({ tooltipDisabled: true }); - const tooltip = getBinding(wrapper.element, 'gl-tooltip'); - expect(tooltip.value.disabled).toBe(true); + expect(findTooltip().props('disabled')).toBe(true); }); - describe('button', () => { - it('exists', () => { - mountComponent(); - expect(findButton().exists()).toBe(true); + it('works with a link', () => { + mountComponent({ + tooltipTitle: LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION, + tooltipLink: 'foo', }); + expect(findTooltip().text()).toMatchInterpolatedText( + LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION, + ); + }); + }); - it('has the correct props/attributes bound', () => { - mountComponent({ disabled: true }); - expect(findButton().attributes()).toMatchObject({ - 'aria-label': 'Foo title', - icon: 'remove', - title: 'Foo title', - variant: 'danger', - disabled: 'true', - category: 'secondary', - }); - }); + describe('button', () => { + it('exists', () => { + mountComponent(); + expect(findButton().exists()).toBe(true); + }); - it('emits a delete event', () => { - mountComponent(); - expect(wrapper.emitted('delete')).toEqual(undefined); - findButton().vm.$emit('click'); - expect(wrapper.emitted('delete')).toEqual([[]]); + it('has the correct props/attributes bound', () => { + mountComponent({ disabled: true }); + expect(findButton().attributes()).toMatchObject({ + 'aria-label': 'Foo title', + icon: 'remove', + title: 'Foo title', + variant: 'danger', + disabled: 'true', + category: 'secondary', }); }); + + it('emits a delete event', () => { + mountComponent(); + expect(wrapper.emitted('delete')).toEqual(undefined); + findButton().vm.$emit('click'); + expect(wrapper.emitted('delete')).toEqual([[]]); + }); }); }); 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 411bef54e40..690d827ec67 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 @@ -10,6 +10,7 @@ import { LIST_DELETE_BUTTON_DISABLED, REMOVE_REPOSITORY_LABEL, IMAGE_DELETE_SCHEDULED_STATUS, + IMAGE_MIGRATING_STATE, SCHEDULED_STATUS, ROOT_IMAGE_TEXT, } from '~/packages_and_registries/container_registry/explorer/constants'; @@ -41,6 +42,9 @@ describe('Image List Row', () => { item, ...props, }, + provide: { + config: {}, + }, directives: { GlTooltip: createMockDirective(), }, @@ -178,6 +182,12 @@ describe('Image List Row', () => { expect(findDeleteBtn().props('disabled')).toBe(state); }, ); + + it('is disabled when migrationState is importing', () => { + mountComponent({ item: { ...item, migrationState: IMAGE_MIGRATING_STATE } }); + + expect(findDeleteBtn().props('disabled')).toBe(true); + }); }); describe('tags count', () => { diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js index c91a9c0f0fb..7d09c09d03b 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js @@ -1,4 +1,4 @@ -import { GlSprintf } from '@gitlab/ui'; +import { GlSprintf, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import Component from '~/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue'; @@ -6,6 +6,7 @@ import { CONTAINER_REGISTRY_TITLE, LIST_INTRO_TEXT, EXPIRATION_POLICY_DISABLED_TEXT, + SET_UP_CLEANUP, } from '~/packages_and_registries/container_registry/explorer/constants'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; @@ -21,6 +22,7 @@ describe('registry_header', () => { const findCommandsSlot = () => wrapper.find('[data-testid="commands-slot"]'); const findImagesCountSubHeader = () => wrapper.find('[data-testid="images-count"]'); const findExpirationPolicySubHeader = () => wrapper.find('[data-testid="expiration-policy"]'); + const findSetupCleanUpLink = () => wrapper.findComponent(GlLink); const mountComponent = async (propsData, slots) => { wrapper = shallowMount(Component, { @@ -88,6 +90,7 @@ describe('registry_header', () => { }); const text = findExpirationPolicySubHeader(); + expect(text.exists()).toBe(true); expect(text.props()).toMatchObject({ text: EXPIRATION_POLICY_DISABLED_TEXT, @@ -100,12 +103,17 @@ describe('registry_header', () => { await mountComponent({ expirationPolicy: { enabled: true }, expirationPolicyHelpPagePath: 'foo', + showCleanupPolicyLink: true, imagesCount: 1, }); const text = findExpirationPolicySubHeader(); + const cleanupLink = findSetupCleanUpLink(); + expect(text.exists()).toBe(true); expect(text.props('text')).toBe('Expiration policy will run in '); + expect(cleanupLink.exists()).toBe(true); + expect(cleanupLink.text()).toBe(SET_UP_CLEANUP); }); it('when the expiration policy is completely disabled', async () => { await mountComponent({ 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 fda1db4b7e1..7e6f88fe5bc 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 @@ -5,6 +5,7 @@ export const imagesListResponse = [ name: 'rails-12009', path: 'gitlab-org/gitlab-test/rails-12009', status: null, + migrationState: 'default', location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-12009', canDelete: true, createdAt: '2020-11-03T13:29:21Z', @@ -17,6 +18,7 @@ export const imagesListResponse = [ name: 'rails-20572', path: 'gitlab-org/gitlab-test/rails-20572', status: null, + migrationState: 'default', location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-20572', canDelete: true, createdAt: '2020-09-21T06:57:43Z', diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js index da4bfcde217..79403d29d18 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js @@ -6,7 +6,6 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; -import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue'; import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import DeleteImage from '~/packages_and_registries/container_registry/explorer/components/delete_image.vue'; import CliCommands from '~/packages_and_registries/shared/components/cli_commands.vue'; @@ -58,7 +57,6 @@ describe('List Page', () => { const findPersistedSearch = () => wrapper.findComponent(PersistedSearch); const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]'); const findDeleteImage = () => wrapper.findComponent(DeleteImage); - const findCleanupAlert = () => wrapper.findComponent(CleanupPolicyEnabledAlert); const fireFirstSortUpdate = () => { findPersistedSearch().vm.$emit('update', { sort: 'UPDATED_DESC', filters: [] }); @@ -511,33 +509,4 @@ describe('List Page', () => { testTrackingCall('confirm_delete'); }); }); - - describe('cleanup is on alert', () => { - it('exist when showCleanupPolicyOnAlert is true and has the correct props', async () => { - mountComponent({ - config: { - showCleanupPolicyOnAlert: true, - projectPath: 'foo', - isGroupPage: false, - cleanupPoliciesSettingsPath: 'bar', - }, - }); - - await waitForApolloRequestRender(); - - expect(findCleanupAlert().exists()).toBe(true); - expect(findCleanupAlert().props()).toMatchObject({ - projectPath: 'foo', - cleanupPoliciesSettingsPath: 'bar', - }); - }); - - it('is hidden when showCleanupPolicyOnAlert is false', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - expect(findCleanupAlert().exists()).toBe(false); - }); - }); }); 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 79894e25889..dbe9793fb8c 100644 --- a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js +++ b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js @@ -1,19 +1,26 @@ import { + GlAlert, + GlDropdown, + GlDropdownItem, GlFormInputGroup, GlFormGroup, + GlModal, GlSkeletonLoader, GlSprintf, GlEmptyState, } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import MockAdapter from 'axios-mock-adapter'; import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { stripTypenames } from 'helpers/graphql_helpers'; import waitForPromises from 'helpers/wait_for_promises'; import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/dependency_proxy/constants'; +import axios from '~/lib/utils/axios_utils'; import DependencyProxyApp from '~/packages_and_registries/dependency_proxy/app.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ManifestsList from '~/packages_and_registries/dependency_proxy/components/manifests_list.vue'; @@ -21,13 +28,25 @@ import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency import { proxyDetailsQuery, proxyData, pagination, proxyManifests } from './mock_data'; +const dummyApiVersion = 'v3000'; +const dummyGrouptId = 1; +const dummyUrlRoot = '/gitlab'; +const dummyGon = { + api_version: dummyApiVersion, + relative_url_root: dummyUrlRoot, +}; +let originalGon; +const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${dummyGrouptId}/dependency_proxy/cache`; + describe('DependencyProxyApp', () => { let wrapper; let apolloProvider; let resolver; + let mock; const provideDefaults = { groupPath: 'gitlab-org', + groupId: dummyGrouptId, dependencyProxyAvailable: true, noManifestsIllustration: 'noManifestsIllustration', }; @@ -43,9 +62,14 @@ describe('DependencyProxyApp', () => { apolloProvider, provide, stubs: { + GlAlert, + GlDropdown, + GlDropdownItem, GlFormInputGroup, GlFormGroup, + GlModal, GlSprintf, + TitleArea, }, }); } @@ -59,13 +83,24 @@ describe('DependencyProxyApp', () => { const findProxyCountText = () => wrapper.findByTestId('proxy-count'); const findManifestList = () => wrapper.findComponent(ManifestsList); const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findClearCacheDropdownList = () => wrapper.findComponent(GlDropdown); + const findClearCacheModal = () => wrapper.findComponent(GlModal); + const findClearCacheAlert = () => wrapper.findComponent(GlAlert); beforeEach(() => { resolver = jest.fn().mockResolvedValue(proxyDetailsQuery()); + + originalGon = window.gon; + window.gon = { ...dummyGon }; + + mock = new MockAdapter(axios); + mock.onDelete(expectedUrl).reply(202, {}); }); afterEach(() => { wrapper.destroy(); + window.gon = originalGon; + mock.restore(); }); describe('when the dependency proxy is not available', () => { @@ -95,6 +130,12 @@ describe('DependencyProxyApp', () => { expect(resolver).not.toHaveBeenCalled(); }); + + it('hides the clear cache dropdown list', () => { + createComponent(createComponentArguments); + + expect(findClearCacheDropdownList().exists()).toBe(false); + }); }); describe('when the dependency proxy is available', () => { @@ -165,6 +206,7 @@ describe('DependencyProxyApp', () => { }), ); createComponent(); + return waitForPromises(); }); @@ -214,6 +256,34 @@ describe('DependencyProxyApp', () => { fullPath: provideDefaults.groupPath, }); }); + + it('shows the clear cache dropdown list', () => { + expect(findClearCacheDropdownList().exists()).toBe(true); + + const clearCacheDropdownItem = findClearCacheDropdownList().findComponent( + GlDropdownItem, + ); + + expect(clearCacheDropdownItem.text()).toBe('Clear cache'); + }); + + it('shows the clear cache confirmation modal', () => { + const modal = findClearCacheModal(); + + expect(modal.find('.modal-title').text()).toContain('Clear 2 images from cache?'); + expect(modal.props('actionPrimary').text).toBe('Clear cache'); + }); + + it('submits the clear cache request', async () => { + findClearCacheModal().vm.$emit('primary', { preventDefault: jest.fn() }); + + await waitForPromises(); + + expect(findClearCacheAlert().exists()).toBe(true); + expect(findClearCacheAlert().text()).toBe( + 'All items in the cache are scheduled for removal.', + ); + }); }); }); }); diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_header_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_header_spec.js new file mode 100644 index 00000000000..636f3eeb04a --- /dev/null +++ b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_header_spec.js @@ -0,0 +1,88 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlSprintf } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import HarborListHeader from '~/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue'; +import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; +import { + HARBOR_REGISTRY_TITLE, + LIST_INTRO_TEXT, +} from '~/packages_and_registries/harbor_registry/constants/index'; + +describe('harbor_list_header', () => { + let wrapper; + + const findTitleArea = () => wrapper.find(TitleArea); + const findCommandsSlot = () => wrapper.find('[data-testid="commands-slot"]'); + const findImagesMetaDataItem = () => wrapper.find(MetadataItem); + + const mountComponent = async (propsData, slots) => { + wrapper = shallowMount(HarborListHeader, { + stubs: { + GlSprintf, + TitleArea, + }, + propsData, + slots, + }); + await nextTick(); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('header', () => { + it('has a title', () => { + mountComponent({ metadataLoading: true }); + + expect(findTitleArea().props()).toMatchObject({ + title: HARBOR_REGISTRY_TITLE, + metadataLoading: true, + }); + }); + + it('has a commands slot', () => { + mountComponent(null, { commands: '<div data-testid="commands-slot">baz</div>' }); + + expect(findCommandsSlot().text()).toBe('baz'); + }); + + describe('sub header parts', () => { + describe('images count', () => { + it('exists', async () => { + await mountComponent({ imagesCount: 1 }); + + expect(findImagesMetaDataItem().exists()).toBe(true); + }); + + it('when there is one image', async () => { + await mountComponent({ imagesCount: 1 }); + + expect(findImagesMetaDataItem().props()).toMatchObject({ + text: '1 Image repository', + icon: 'container-image', + }); + }); + + it('when there is more than one image', async () => { + await mountComponent({ imagesCount: 3 }); + + expect(findImagesMetaDataItem().props('text')).toBe('3 Image repositories'); + }); + }); + }); + }); + + describe('info messages', () => { + describe('default message', () => { + it('is correctly bound to title_area props', () => { + mountComponent({ helpPagePath: 'foo' }); + + expect(findTitleArea().props('infoMessages')).toEqual([ + { text: LIST_INTRO_TEXT, link: 'foo' }, + ]); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_row_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_row_spec.js new file mode 100644 index 00000000000..8560c4f78f7 --- /dev/null +++ b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_row_spec.js @@ -0,0 +1,99 @@ +import { shallowMount, RouterLinkStub as RouterLink } from '@vue/test-utils'; +import { GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui'; + +import HarborListRow from '~/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { harborListResponse } from '../../mock_data'; + +describe('Harbor List Row', () => { + let wrapper; + const [item] = harborListResponse.repositories; + + const findDetailsLink = () => wrapper.find(RouterLink); + const findClipboardButton = () => wrapper.findComponent(ClipboardButton); + const findTagsCount = () => wrapper.find('[data-testid="tags-count"]'); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + + const mountComponent = (props) => { + wrapper = shallowMount(HarborListRow, { + stubs: { + RouterLink, + GlSprintf, + ListItem, + }, + propsData: { + item, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('image title and path', () => { + it('contains a link to the details page', () => { + mountComponent(); + + const link = findDetailsLink(); + expect(link.text()).toBe(item.name); + expect(findDetailsLink().props('to')).toMatchObject({ + name: 'details', + params: { + id: item.id, + }, + }); + }); + + it('contains a clipboard button', () => { + mountComponent(); + const button = findClipboardButton(); + expect(button.exists()).toBe(true); + expect(button.props('text')).toBe(item.location); + expect(button.props('title')).toBe(item.location); + }); + }); + + describe('tags count', () => { + it('exists', () => { + mountComponent(); + expect(findTagsCount().exists()).toBe(true); + }); + + it('contains a tag icon', () => { + mountComponent(); + const icon = findTagsCount().find(GlIcon); + expect(icon.exists()).toBe(true); + expect(icon.props('name')).toBe('tag'); + }); + + describe('loading state', () => { + it('shows a loader when metadataLoading is true', () => { + mountComponent({ metadataLoading: true }); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('hides the tags count while loading', () => { + mountComponent({ metadataLoading: true }); + + expect(findTagsCount().exists()).toBe(false); + }); + }); + + describe('tags count text', () => { + it('with one tag in the image', () => { + mountComponent({ item: { ...item, artifactCount: 1 } }); + + expect(findTagsCount().text()).toMatchInterpolatedText('1 Tag'); + }); + it('with more than one tag in the image', () => { + mountComponent({ item: { ...item, artifactCount: 3 } }); + + expect(findTagsCount().text()).toMatchInterpolatedText('3 Tags'); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_spec.js new file mode 100644 index 00000000000..f018eff58c9 --- /dev/null +++ b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_spec.js @@ -0,0 +1,39 @@ +import { shallowMount } from '@vue/test-utils'; +import HarborList from '~/packages_and_registries/harbor_registry/components/list/harbor_list.vue'; +import HarborListRow from '~/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue'; +import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import { harborListResponse } from '../../mock_data'; + +describe('Harbor List', () => { + let wrapper; + + const findHarborListRow = () => wrapper.findAll(HarborListRow); + + const mountComponent = (props) => { + wrapper = shallowMount(HarborList, { + stubs: { RegistryList }, + propsData: { + images: harborListResponse.repositories, + pageInfo: harborListResponse.pageInfo, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('list', () => { + it('contains one list element for each image', () => { + mountComponent(); + + expect(findHarborListRow().length).toBe(harborListResponse.repositories.length); + }); + + it('passes down the metadataLoading prop', () => { + mountComponent({ metadataLoading: true }); + expect(findHarborListRow().at(0).props('metadataLoading')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/harbor_registry/mock_data.js b/spec/frontend/packages_and_registries/harbor_registry/mock_data.js new file mode 100644 index 00000000000..85399c22e79 --- /dev/null +++ b/spec/frontend/packages_and_registries/harbor_registry/mock_data.js @@ -0,0 +1,175 @@ +export const harborListResponse = { + repositories: [ + { + artifactCount: 1, + creationTime: '2022-03-02T06:35:53.205Z', + id: 25, + name: 'shao/flinkx', + projectId: 21, + pullCount: 0, + updateTime: '2022-03-02T06:35:53.205Z', + location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', + }, + { + artifactCount: 1, + creationTime: '2022-03-02T06:35:53.205Z', + id: 26, + name: 'shao/flinkx1', + projectId: 21, + pullCount: 0, + updateTime: '2022-03-02T06:35:53.205Z', + location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', + }, + { + artifactCount: 1, + creationTime: '2022-03-02T06:35:53.205Z', + id: 27, + name: 'shao/flinkx2', + projectId: 21, + pullCount: 0, + updateTime: '2022-03-02T06:35:53.205Z', + location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', + }, + ], + totalCount: 3, + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + }, +}; + +export const harborTagsResponse = { + tags: [ + { + digest: 'sha256:7f386a1844faf341353e1c20f2f39f11f397604fedc475435d13f756eeb235d1', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c', + name: '02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c', + revision: 'f53bde3d44699e04e11cf15fb415046a0913e2623d878d89bc21adb2cbda5255', + shortRevision: 'f53bde3d4', + createdAt: '2022-03-02T23:59:05+00:00', + totalSize: '6623124', + }, + { + digest: 'sha256:4554416b84c4568fe93086620b637064ed029737aabe7308b96d50e3d9d92ed7', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160', + name: '02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160', + revision: 'e1fe52d8bab66d71bd54a6b8784d3b9edbc68adbd6ea87f5fa44d9974144ef9e', + shortRevision: 'e1fe52d8b', + createdAt: '2022-02-10T01:09:56+00:00', + totalSize: '920760', + }, + { + digest: 'sha256:14f37b60e52b9ce0e9f8f7094b311d265384798592f783487c30aaa3d58e6345', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a', + name: '03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a', + revision: 'c72770c6eb93c421bc496964b4bffc742b1ec2e642cdab876be7afda1856029f', + shortRevision: 'c72770c6e', + createdAt: '2021-12-22T04:48:48+00:00', + totalSize: '48609053', + }, + { + digest: 'sha256:e925e3b8277ea23f387ed5fba5e78280cfac7cfb261a78cf046becf7b6a3faae', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19', + name: '03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19', + revision: '1ac2a43194f4e15166abdf3f26e6ec92215240490b9cac834d63de1a3d87494a', + shortRevision: '1ac2a4319', + createdAt: '2022-03-09T11:02:27+00:00', + totalSize: '35141894', + }, + { + digest: 'sha256:7d8303fd5c077787a8c879f8f66b69e2b5605f48ccd3f286e236fb0749fcc1ca', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda', + name: '05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda', + revision: 'cf8fee086701016e1a84e6824f0c896951fef4cce9d4745459558b87eec3232c', + shortRevision: 'cf8fee086', + createdAt: '2022-01-21T11:31:43+00:00', + totalSize: '48716070', + }, + { + digest: 'sha256:b33611cefe20e4a41a6e0dce356a5d7ef3c177ea7536a58652f5b3a4f2f83549', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a', + name: '093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a', + revision: '1a4b48198b13d55242c5164e64d41c4e9f75b5d9506bc6e0efc1534dd0dd1f15', + shortRevision: '1a4b48198', + createdAt: '2022-01-21T11:31:51+00:00', + totalSize: '6623127', + }, + { + digest: 'sha256:d25c3c020e2dbd4711a67b9fe308f4cbb7b0bb21815e722a02f91c570dc5d519', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7', + name: '09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7', + revision: '03e2e2777dde01c30469ee8c710973dd08a7a4f70494d7dc1583c24b525d7f61', + shortRevision: '03e2e2777', + createdAt: '2022-03-02T23:58:20+00:00', + totalSize: '911377', + }, + { + digest: 'sha256:fb760e4d2184e9e8e39d6917534d4610fe01009734698a5653b2de1391ba28f4', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95', + name: '09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95', + revision: '350e78d60646bf6967244448c6aaa14d21ecb9a0c6cf87e9ff0361cbe59b9012', + shortRevision: '350e78d60', + createdAt: '2022-01-19T13:49:14+00:00', + totalSize: '48710241', + }, + { + digest: 'sha256:407250f380cea92729cbc038c420e74900f53b852e11edc6404fe75a0fd2c402', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557', + name: '0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557', + revision: '76038370b7f3904364891457c4a6a234897255e6b9f45d0a852bf3a7e5257e18', + shortRevision: '76038370b', + createdAt: '2022-01-24T12:56:22+00:00', + totalSize: '280065', + }, + { + digest: 'sha256:ada87f25218542951ce6720c27f3d0758e90c2540bd129f5cfb9e15b31e07b07', + location: + 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb', + path: + 'gitlab-org/gitlab/gitlab-ee-qa/cache:0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb', + name: '0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb', + revision: '3d4b49a7bbb36c48bb721f4d0e76e7950bec3878ee29cdfdd6da39f575d6d37f', + shortRevision: '3d4b49a7b', + createdAt: '2022-02-17T17:37:52+00:00', + totalSize: '48655767', + }, + ], + totalCount: 100, + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + }, +}; + +export const dockerCommands = { + dockerBuildCommand: 'foofoo', + dockerPushCommand: 'barbar', + dockerLoginCommand: 'bazbaz', +}; diff --git a/spec/frontend/packages_and_registries/harbor_registry/pages/index_spec.js b/spec/frontend/packages_and_registries/harbor_registry/pages/index_spec.js new file mode 100644 index 00000000000..55fc8066f65 --- /dev/null +++ b/spec/frontend/packages_and_registries/harbor_registry/pages/index_spec.js @@ -0,0 +1,24 @@ +import { shallowMount } from '@vue/test-utils'; +import component from '~/packages_and_registries/harbor_registry/pages/index.vue'; + +describe('List Page', () => { + let wrapper; + + const findRouterView = () => wrapper.find({ ref: 'router-view' }); + + const mountComponent = () => { + wrapper = shallowMount(component, { + stubs: { + RouterView: true, + }, + }); + }; + + beforeEach(() => { + mountComponent(); + }); + + it('has a router view', () => { + expect(findRouterView().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js b/spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js new file mode 100644 index 00000000000..61ee36a2794 --- /dev/null +++ b/spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js @@ -0,0 +1,140 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { GlSkeletonLoader } from '@gitlab/ui'; +import HarborListHeader from '~/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue'; +import HarborRegistryList from '~/packages_and_registries/harbor_registry/pages/list.vue'; +import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; +import waitForPromises from 'helpers/wait_for_promises'; +// import { harborListResponse } from '~/packages_and_registries/harbor_registry/mock_api.js'; +import HarborList from '~/packages_and_registries/harbor_registry/components/list/harbor_list.vue'; +import CliCommands from '~/packages_and_registries/shared/components/cli_commands.vue'; +import { SORT_FIELDS } from '~/packages_and_registries/harbor_registry/constants/index'; +import { harborListResponse, dockerCommands } from '../mock_data'; + +let mockHarborListResponse; +jest.mock('~/packages_and_registries/harbor_registry/mock_api.js', () => ({ + harborListResponse: () => mockHarborListResponse, +})); + +describe('Harbor List Page', () => { + let wrapper; + + const waitForHarborPageRequest = async () => { + await waitForPromises(); + await nextTick(); + }; + + beforeEach(() => { + mockHarborListResponse = Promise.resolve(harborListResponse); + }); + + const findHarborListHeader = () => wrapper.findComponent(HarborListHeader); + const findPersistedSearch = () => wrapper.findComponent(PersistedSearch); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findHarborList = () => wrapper.findComponent(HarborList); + const findCliCommands = () => wrapper.findComponent(CliCommands); + + const fireFirstSortUpdate = () => { + findPersistedSearch().vm.$emit('update', { sort: 'UPDATED_DESC', filters: [] }); + }; + + const mountComponent = ({ config = { isGroupPage: false } } = {}) => { + wrapper = shallowMount(HarborRegistryList, { + stubs: { + HarborListHeader, + }, + provide() { + return { + config, + ...dockerCommands, + }; + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains harbor registry header', async () => { + mountComponent(); + fireFirstSortUpdate(); + await waitForHarborPageRequest(); + await nextTick(); + + expect(findHarborListHeader().exists()).toBe(true); + expect(findHarborListHeader().props()).toMatchObject({ + imagesCount: 3, + metadataLoading: false, + }); + }); + + describe('isLoading is true', () => { + it('shows the skeleton loader', async () => { + mountComponent(); + fireFirstSortUpdate(); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('harborList is not visible', () => { + mountComponent(); + + expect(findHarborList().exists()).toBe(false); + }); + + it('cli commands is not visible', () => { + mountComponent(); + + expect(findCliCommands().exists()).toBe(false); + }); + + it('title has the metadataLoading props set to true', async () => { + mountComponent(); + fireFirstSortUpdate(); + + expect(findHarborListHeader().props('metadataLoading')).toBe(true); + }); + }); + + describe('list is not empty', () => { + describe('unfiltered state', () => { + it('quick start is visible', async () => { + mountComponent(); + fireFirstSortUpdate(); + + await waitForHarborPageRequest(); + await nextTick(); + + expect(findCliCommands().exists()).toBe(true); + }); + + it('list component is visible', async () => { + mountComponent(); + fireFirstSortUpdate(); + + await waitForHarborPageRequest(); + await nextTick(); + + expect(findHarborList().exists()).toBe(true); + }); + }); + + describe('search and sorting', () => { + it('has a persisted search box element', async () => { + mountComponent(); + fireFirstSortUpdate(); + await waitForHarborPageRequest(); + await nextTick(); + + const harborRegistrySearch = findPersistedSearch(); + expect(harborRegistrySearch.exists()).toBe(true); + expect(harborRegistrySearch.props()).toMatchObject({ + defaultOrder: 'UPDATED', + defaultSort: 'desc', + sortableFields: SORT_FIELDS, + }); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js index b9383d6c38c..31ab108558c 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js @@ -20,10 +20,10 @@ jest.mock('~/api.js'); describe('Actions Package details store', () => { describe('fetchPackageVersions', () => { - it('should fetch the package versions', (done) => { + it('should fetch the package versions', async () => { Api.projectPackage = jest.fn().mockResolvedValue({ data: packageEntity }); - testAction( + await testAction( fetchPackageVersions, undefined, { packageEntity }, @@ -33,20 +33,14 @@ describe('Actions Package details store', () => { { type: types.SET_LOADING, payload: false }, ], [], - () => { - expect(Api.projectPackage).toHaveBeenCalledWith( - packageEntity.project_id, - packageEntity.id, - ); - done(); - }, ); + expect(Api.projectPackage).toHaveBeenCalledWith(packageEntity.project_id, packageEntity.id); }); - it("does not set the versions if they don't exist", (done) => { + it("does not set the versions if they don't exist", async () => { Api.projectPackage = jest.fn().mockResolvedValue({ data: { packageEntity, versions: null } }); - testAction( + await testAction( fetchPackageVersions, undefined, { packageEntity }, @@ -55,20 +49,14 @@ describe('Actions Package details store', () => { { type: types.SET_LOADING, payload: false }, ], [], - () => { - expect(Api.projectPackage).toHaveBeenCalledWith( - packageEntity.project_id, - packageEntity.id, - ); - done(); - }, ); + expect(Api.projectPackage).toHaveBeenCalledWith(packageEntity.project_id, packageEntity.id); }); - it('should create flash on API error', (done) => { + it('should create flash on API error', async () => { Api.projectPackage = jest.fn().mockRejectedValue(); - testAction( + await testAction( fetchPackageVersions, undefined, { packageEntity }, @@ -77,41 +65,31 @@ describe('Actions Package details store', () => { { type: types.SET_LOADING, payload: false }, ], [], - () => { - expect(Api.projectPackage).toHaveBeenCalledWith( - packageEntity.project_id, - packageEntity.id, - ); - expect(createFlash).toHaveBeenCalledWith({ - message: FETCH_PACKAGE_VERSIONS_ERROR, - type: 'warning', - }); - done(); - }, ); + expect(Api.projectPackage).toHaveBeenCalledWith(packageEntity.project_id, packageEntity.id); + expect(createFlash).toHaveBeenCalledWith({ + message: FETCH_PACKAGE_VERSIONS_ERROR, + type: 'warning', + }); }); }); describe('deletePackage', () => { - it('should call Api.deleteProjectPackage', (done) => { + it('should call Api.deleteProjectPackage', async () => { Api.deleteProjectPackage = jest.fn().mockResolvedValue(); - testAction(deletePackage, undefined, { packageEntity }, [], [], () => { - expect(Api.deleteProjectPackage).toHaveBeenCalledWith( - packageEntity.project_id, - packageEntity.id, - ); - done(); - }); + await testAction(deletePackage, undefined, { packageEntity }, [], []); + expect(Api.deleteProjectPackage).toHaveBeenCalledWith( + packageEntity.project_id, + packageEntity.id, + ); }); - it('should create flash on API error', (done) => { + it('should create flash on API error', async () => { Api.deleteProjectPackage = jest.fn().mockRejectedValue(); - testAction(deletePackage, undefined, { packageEntity }, [], [], () => { - expect(createFlash).toHaveBeenCalledWith({ - message: DELETE_PACKAGE_ERROR_MESSAGE, - type: 'warning', - }); - done(); + await testAction(deletePackage, undefined, { packageEntity }, [], []); + expect(createFlash).toHaveBeenCalledWith({ + message: DELETE_PACKAGE_ERROR_MESSAGE, + type: 'warning', }); }); }); @@ -119,37 +97,33 @@ describe('Actions Package details store', () => { describe('deletePackageFile', () => { const fileId = 'a_file_id'; - it('should call Api.deleteProjectPackageFile and commit the right data', (done) => { + it('should call Api.deleteProjectPackageFile and commit the right data', async () => { const packageFiles = [{ id: 'foo' }, { id: fileId }]; Api.deleteProjectPackageFile = jest.fn().mockResolvedValue(); - testAction( + await testAction( deletePackageFile, fileId, { packageEntity, packageFiles }, [{ type: types.UPDATE_PACKAGE_FILES, payload: [{ id: 'foo' }] }], [], - () => { - expect(Api.deleteProjectPackageFile).toHaveBeenCalledWith( - packageEntity.project_id, - packageEntity.id, - fileId, - ); - expect(createFlash).toHaveBeenCalledWith({ - message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, - type: 'success', - }); - done(); - }, ); + expect(Api.deleteProjectPackageFile).toHaveBeenCalledWith( + packageEntity.project_id, + packageEntity.id, + fileId, + ); + expect(createFlash).toHaveBeenCalledWith({ + message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, + type: 'success', + }); }); - it('should create flash on API error', (done) => { + + it('should create flash on API error', async () => { Api.deleteProjectPackageFile = jest.fn().mockRejectedValue(); - testAction(deletePackageFile, fileId, { packageEntity }, [], [], () => { - expect(createFlash).toHaveBeenCalledWith({ - message: DELETE_PACKAGE_FILE_ERROR_MESSAGE, - type: 'warning', - }); - done(); + await testAction(deletePackageFile, fileId, { packageEntity }, [], []); + expect(createFlash).toHaveBeenCalledWith({ + message: DELETE_PACKAGE_FILE_ERROR_MESSAGE, + type: 'warning', }); }); }); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap index d82af8f9e63..a33528d2d91 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap @@ -21,7 +21,7 @@ exports[`packages_list_app renders 1`] = ` > <img alt="" - class="gl-max-w-full" + class="gl-max-w-full gl-dark-invert-keep-hue" role="img" src="helpSvg" /> diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js index 3fbfe1060dc..d596f2dae33 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js @@ -32,8 +32,8 @@ describe('Actions Package list store', () => { }; const filter = []; - it('should fetch the project packages list when isGroupPage is false', (done) => { - testAction( + it('should fetch the project packages list when isGroupPage is false', async () => { + await testAction( actions.requestPackagesList, undefined, { config: { isGroupPage: false, resourceId: 1 }, sorting, filter }, @@ -43,17 +43,14 @@ describe('Actions Package list store', () => { { type: 'receivePackagesListSuccess', payload: { data: 'foo', headers } }, { type: 'setLoading', payload: false }, ], - () => { - expect(Api.projectPackages).toHaveBeenCalledWith(1, { - params: { page: 1, per_page: 20, sort: sorting.sort, order_by: sorting.orderBy }, - }); - done(); - }, ); + expect(Api.projectPackages).toHaveBeenCalledWith(1, { + params: { page: 1, per_page: 20, sort: sorting.sort, order_by: sorting.orderBy }, + }); }); - it('should fetch the group packages list when isGroupPage is true', (done) => { - testAction( + it('should fetch the group packages list when isGroupPage is true', async () => { + await testAction( actions.requestPackagesList, undefined, { config: { isGroupPage: true, resourceId: 2 }, sorting, filter }, @@ -63,19 +60,16 @@ describe('Actions Package list store', () => { { type: 'receivePackagesListSuccess', payload: { data: 'baz', headers } }, { type: 'setLoading', payload: false }, ], - () => { - expect(Api.groupPackages).toHaveBeenCalledWith(2, { - params: { page: 1, per_page: 20, sort: sorting.sort, order_by: sorting.orderBy }, - }); - done(); - }, ); + expect(Api.groupPackages).toHaveBeenCalledWith(2, { + params: { page: 1, per_page: 20, sort: sorting.sort, order_by: sorting.orderBy }, + }); }); - it('should fetch packages of a certain type when a filter with a type is present', (done) => { + it('should fetch packages of a certain type when a filter with a type is present', async () => { const packageType = 'maven'; - testAction( + await testAction( actions.requestPackagesList, undefined, { @@ -89,24 +83,21 @@ describe('Actions Package list store', () => { { type: 'receivePackagesListSuccess', payload: { data: 'foo', headers } }, { type: 'setLoading', payload: false }, ], - () => { - expect(Api.projectPackages).toHaveBeenCalledWith(1, { - params: { - page: 1, - per_page: 20, - sort: sorting.sort, - order_by: sorting.orderBy, - package_type: packageType, - }, - }); - done(); - }, ); + expect(Api.projectPackages).toHaveBeenCalledWith(1, { + params: { + page: 1, + per_page: 20, + sort: sorting.sort, + order_by: sorting.orderBy, + package_type: packageType, + }, + }); }); - it('should create flash on API error', (done) => { + it('should create flash on API error', async () => { Api.projectPackages = jest.fn().mockRejectedValue(); - testAction( + await testAction( actions.requestPackagesList, undefined, { config: { isGroupPage: false, resourceId: 2 }, sorting, filter }, @@ -115,15 +106,12 @@ describe('Actions Package list store', () => { { type: 'setLoading', payload: true }, { type: 'setLoading', payload: false }, ], - () => { - expect(createFlash).toHaveBeenCalled(); - done(); - }, ); + expect(createFlash).toHaveBeenCalled(); }); - it('should force the terraform_module type when forceTerraform is true', (done) => { - testAction( + it('should force the terraform_module type when forceTerraform is true', async () => { + await testAction( actions.requestPackagesList, undefined, { config: { isGroupPage: false, resourceId: 1, forceTerraform: true }, sorting, filter }, @@ -133,27 +121,24 @@ describe('Actions Package list store', () => { { type: 'receivePackagesListSuccess', payload: { data: 'foo', headers } }, { type: 'setLoading', payload: false }, ], - () => { - expect(Api.projectPackages).toHaveBeenCalledWith(1, { - params: { - page: 1, - per_page: 20, - sort: sorting.sort, - order_by: sorting.orderBy, - package_type: 'terraform_module', - }, - }); - done(); - }, ); + expect(Api.projectPackages).toHaveBeenCalledWith(1, { + params: { + page: 1, + per_page: 20, + sort: sorting.sort, + order_by: sorting.orderBy, + package_type: 'terraform_module', + }, + }); }); }); describe('receivePackagesListSuccess', () => { - it('should set received packages', (done) => { + it('should set received packages', () => { const data = 'foo'; - testAction( + return testAction( actions.receivePackagesListSuccess, { data, headers }, null, @@ -162,33 +147,30 @@ describe('Actions Package list store', () => { { type: types.SET_PAGINATION, payload: headers }, ], [], - done, ); }); }); describe('setInitialState', () => { - it('should commit setInitialState', (done) => { - testAction( + it('should commit setInitialState', () => { + return testAction( actions.setInitialState, '1', null, [{ type: types.SET_INITIAL_STATE, payload: '1' }], [], - done, ); }); }); describe('setLoading', () => { - it('should commit set main loading', (done) => { - testAction( + it('should commit set main loading', () => { + return testAction( actions.setLoading, true, null, [{ type: types.SET_MAIN_LOADING, payload: true }], [], - done, ); }); }); @@ -199,11 +181,11 @@ describe('Actions Package list store', () => { delete_api_path: 'foo', }, }; - it('should perform a delete operation on _links.delete_api_path', (done) => { + it('should perform a delete operation on _links.delete_api_path', () => { mock.onDelete(payload._links.delete_api_path).replyOnce(200); Api.projectPackages = jest.fn().mockResolvedValue({ data: 'foo' }); - testAction( + return testAction( actions.requestDeletePackage, payload, { pagination: { page: 1 } }, @@ -212,13 +194,12 @@ describe('Actions Package list store', () => { { type: 'setLoading', payload: true }, { type: 'requestPackagesList', payload: { page: 1 } }, ], - done, ); }); - it('should stop the loading and call create flash on api error', (done) => { + it('should stop the loading and call create flash on api error', async () => { mock.onDelete(payload._links.delete_api_path).replyOnce(400); - testAction( + await testAction( actions.requestDeletePackage, payload, null, @@ -227,50 +208,44 @@ describe('Actions Package list store', () => { { type: 'setLoading', payload: true }, { type: 'setLoading', payload: false }, ], - () => { - expect(createFlash).toHaveBeenCalled(); - done(); - }, ); + expect(createFlash).toHaveBeenCalled(); }); it.each` property | actionPayload ${'_links'} | ${{}} ${'delete_api_path'} | ${{ _links: {} }} - `('should reject and createFlash when $property is missing', ({ actionPayload }, done) => { - testAction(actions.requestDeletePackage, actionPayload, null, [], []).catch((e) => { + `('should reject and createFlash when $property is missing', ({ actionPayload }) => { + return testAction(actions.requestDeletePackage, actionPayload, null, [], []).catch((e) => { expect(e).toEqual(new Error(MISSING_DELETE_PATH_ERROR)); expect(createFlash).toHaveBeenCalledWith({ message: DELETE_PACKAGE_ERROR_MESSAGE, }); - done(); }); }); }); describe('setSorting', () => { - it('should commit SET_SORTING', (done) => { - testAction( + it('should commit SET_SORTING', () => { + return testAction( actions.setSorting, 'foo', null, [{ type: types.SET_SORTING, payload: 'foo' }], [], - done, ); }); }); describe('setFilter', () => { - it('should commit SET_FILTER', (done) => { - testAction( + it('should commit SET_FILTER', () => { + return testAction( actions.setFilter, 'foo', null, [{ type: types.SET_FILTER, payload: 'foo' }], [], - done, ); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js index 9e91b15bc6e..3670cfca8ea 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js @@ -73,7 +73,6 @@ describe('Package Search', () => { mountComponent(); expect(findLocalStorageSync().props()).toMatchObject({ - asJson: true, storageKey: 'package_registry_list_sorting', value: { orderBy: LIST_KEY_CREATED_AT, diff --git a/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap index 0154486e224..17905a8db2d 100644 --- a/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap @@ -21,7 +21,7 @@ exports[`PackagesListApp renders 1`] = ` > <img alt="" - class="gl-max-w-full" + class="gl-max-w-full gl-dark-invert-keep-hue" role="img" src="emptyListIllustration" /> 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 a6c929844b1..0a72f0269ee 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 @@ -12,7 +12,6 @@ import { UNAVAILABLE_USER_FEATURE_TEXT, } from '~/packages_and_registries/settings/project/constants'; import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql'; -import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue'; import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; import { @@ -31,12 +30,11 @@ describe('Registry Settings App', () => { adminSettingsPath: 'settingsPath', enableHistoricEntries: false, helpPagePath: 'helpPagePath', - showCleanupPolicyOnAlert: false, + showCleanupPolicyLink: false, }; const findSettingsComponent = () => wrapper.find(SettingsForm); const findAlert = () => wrapper.find(GlAlert); - const findCleanupAlert = () => wrapper.findComponent(CleanupPolicyEnabledAlert); const mountComponent = (provide = defaultProvidedValues, config) => { wrapper = shallowMount(component, { @@ -69,26 +67,6 @@ describe('Registry Settings App', () => { wrapper.destroy(); }); - describe('cleanup is on alert', () => { - it('exist when showCleanupPolicyOnAlert is true and has the correct props', () => { - mountComponent({ - ...defaultProvidedValues, - showCleanupPolicyOnAlert: true, - }); - - expect(findCleanupAlert().exists()).toBe(true); - expect(findCleanupAlert().props()).toMatchObject({ - projectPath: 'path', - }); - }); - - it('is hidden when showCleanupPolicyOnAlert is false', async () => { - mountComponent(); - - expect(findCleanupAlert().exists()).toBe(false); - }); - }); - describe('isEdited status', () => { it.each` description | apiResponse | workingCopy | result diff --git a/spec/frontend/packages_and_registries/shared/components/__snapshots__/cleanup_policy_enabled_alert_spec.js.snap b/spec/frontend/packages_and_registries/shared/components/__snapshots__/cleanup_policy_enabled_alert_spec.js.snap deleted file mode 100644 index 2cded2ead2e..00000000000 --- a/spec/frontend/packages_and_registries/shared/components/__snapshots__/cleanup_policy_enabled_alert_spec.js.snap +++ /dev/null @@ -1,19 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`CleanupPolicyEnabledAlert renders 1`] = ` -<gl-alert-stub - class="gl-mt-2" - dismissible="true" - dismisslabel="Dismiss" - primarybuttonlink="" - primarybuttontext="" - secondarybuttonlink="" - secondarybuttontext="" - title="" - variant="info" -> - <gl-sprintf-stub - message="Cleanup policies are now available for this project. %{linkStart}Click here to get started.%{linkEnd}" - /> -</gl-alert-stub> -`; diff --git a/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap b/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap index ceae8eebaef..3dd6023140f 100644 --- a/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap +++ b/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap @@ -10,11 +10,10 @@ exports[`Registry Breadcrumb when is not rootRoute renders 1`] = ` class="breadcrumb gl-breadcrumb-list" > <li - class="breadcrumb-item gl-breadcrumb-item" + class="gl-breadcrumb-item" > <a class="" - href="/" target="_self" > <span> @@ -45,9 +44,10 @@ exports[`Registry Breadcrumb when is not rootRoute renders 1`] = ` <!----> <li - class="breadcrumb-item gl-breadcrumb-item" + class="gl-breadcrumb-item" > <a + aria-current="page" class="" href="#" target="_self" @@ -75,11 +75,11 @@ exports[`Registry Breadcrumb when is rootRoute renders 1`] = ` class="breadcrumb gl-breadcrumb-list" > <li - class="breadcrumb-item gl-breadcrumb-item" + class="gl-breadcrumb-item" > <a + aria-current="page" class="" - href="/" target="_self" > <span> diff --git a/spec/frontend/packages_and_registries/shared/components/cleanup_policy_enabled_alert_spec.js b/spec/frontend/packages_and_registries/shared/components/cleanup_policy_enabled_alert_spec.js deleted file mode 100644 index 269e087f5ac..00000000000 --- a/spec/frontend/packages_and_registries/shared/components/cleanup_policy_enabled_alert_spec.js +++ /dev/null @@ -1,49 +0,0 @@ -import { GlAlert } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import component from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue'; -import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; - -describe('CleanupPolicyEnabledAlert', () => { - let wrapper; - - const defaultProps = { - projectPath: 'foo', - cleanupPoliciesSettingsPath: 'label-bar', - }; - - const findAlert = () => wrapper.findComponent(GlAlert); - - const mountComponent = (props) => { - wrapper = shallowMount(component, { - stubs: { - LocalStorageSync, - }, - propsData: { - ...defaultProps, - ...props, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders', () => { - mountComponent(); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('when dismissed is not visible', async () => { - mountComponent(); - - expect(findAlert().exists()).toBe(true); - findAlert().vm.$emit('dismiss'); - - await nextTick(); - - expect(findAlert().exists()).toBe(false); - }); -}); diff --git a/spec/frontend/packages_and_registries/shared/components/registry_breadcrumb_spec.js b/spec/frontend/packages_and_registries/shared/components/registry_breadcrumb_spec.js index 6dfe116c285..15db454ac68 100644 --- a/spec/frontend/packages_and_registries/shared/components/registry_breadcrumb_spec.js +++ b/spec/frontend/packages_and_registries/shared/components/registry_breadcrumb_spec.js @@ -1,4 +1,4 @@ -import { mount } from '@vue/test-utils'; +import { mount, RouterLinkStub } from '@vue/test-utils'; import component from '~/packages_and_registries/shared/components/registry_breadcrumb.vue'; @@ -21,6 +21,9 @@ describe('Registry Breadcrumb', () => { }, }, }, + stubs: { + RouterLink: RouterLinkStub, + }, }); }; @@ -30,7 +33,6 @@ describe('Registry Breadcrumb', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('when is rootRoute', () => { @@ -46,7 +48,6 @@ describe('Registry Breadcrumb', () => { const links = wrapper.findAll('a'); expect(links).toHaveLength(1); - expect(links.at(0).attributes('href')).toBe('/'); }); it('the link text is calculated by nameGenerator', () => { @@ -67,7 +68,6 @@ describe('Registry Breadcrumb', () => { const links = wrapper.findAll('a'); expect(links).toHaveLength(2); - expect(links.at(0).attributes('href')).toBe('/'); expect(links.at(1).attributes('href')).toBe('#'); }); diff --git a/spec/frontend/pager_spec.js b/spec/frontend/pager_spec.js index 043ea470436..9df69124d66 100644 --- a/spec/frontend/pager_spec.js +++ b/spec/frontend/pager_spec.js @@ -68,34 +68,34 @@ describe('pager', () => { it('shows loader while loading next page', async () => { mockSuccess(); - jest.spyOn(Pager.loading, 'show').mockImplementation(() => {}); + jest.spyOn(Pager.$loading, 'show').mockImplementation(() => {}); Pager.getOld(); await waitForPromises(); - expect(Pager.loading.show).toHaveBeenCalled(); + expect(Pager.$loading.show).toHaveBeenCalled(); }); it('hides loader on success', async () => { mockSuccess(); - jest.spyOn(Pager.loading, 'hide').mockImplementation(() => {}); + jest.spyOn(Pager.$loading, 'hide').mockImplementation(() => {}); Pager.getOld(); await waitForPromises(); - expect(Pager.loading.hide).toHaveBeenCalled(); + expect(Pager.$loading.hide).toHaveBeenCalled(); }); it('hides loader on error', async () => { mockError(); - jest.spyOn(Pager.loading, 'hide').mockImplementation(() => {}); + jest.spyOn(Pager.$loading, 'hide').mockImplementation(() => {}); Pager.getOld(); await waitForPromises(); - expect(Pager.loading.hide).toHaveBeenCalled(); + expect(Pager.$loading.hide).toHaveBeenCalled(); }); it('sends request to url with offset and limit params', async () => { @@ -122,12 +122,12 @@ describe('pager', () => { Pager.limit = 20; mockSuccess(1); - jest.spyOn(Pager.loading, 'hide').mockImplementation(() => {}); + jest.spyOn(Pager.$loading, 'hide').mockImplementation(() => {}); Pager.getOld(); await waitForPromises(); - expect(Pager.loading.hide).toHaveBeenCalled(); + expect(Pager.$loading.hide).toHaveBeenCalled(); expect(Pager.disable).toBe(true); }); @@ -175,5 +175,46 @@ describe('pager', () => { expect(axios.get).toHaveBeenCalledWith(href, expect.any(Object)); }); }); + + describe('when `container` is passed', () => { + const href = '/some_list'; + const container = '#js-pager'; + let endlessScrollCallback; + + beforeEach(() => { + jest.spyOn(axios, 'get'); + jest.spyOn($.fn, 'endlessScroll').mockImplementation(({ callback }) => { + endlessScrollCallback = callback; + }); + }); + + describe('when `container` is visible', () => { + it('makes API request', () => { + setFixtures( + `<div id="js-pager"><div class="content_list" data-href="${href}"></div></div>`, + ); + + Pager.init({ container }); + + endlessScrollCallback(); + + expect(axios.get).toHaveBeenCalledWith(href, expect.any(Object)); + }); + }); + + describe('when `container` is not visible', () => { + it('does not make API request', () => { + setFixtures( + `<div id="js-pager" style="display: none;"><div class="content_list" data-href="${href}"></div></div>`, + ); + + Pager.init({ container }); + + endlessScrollCallback(); + + expect(axios.get).not.toHaveBeenCalled(); + }); + }); + }); }); }); diff --git a/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js index 9f326dc33c0..3a4f93d4464 100644 --- a/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js +++ b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js @@ -23,13 +23,12 @@ describe('AccountAndLimits', () => { expect($userInternalRegex.readOnly).toBeTruthy(); }); - it('is checked', (done) => { + it('is checked', () => { if (!$userDefaultExternal.prop('checked')) $userDefaultExternal.click(); expect($userDefaultExternal.prop('checked')).toBeTruthy(); expect($userInternalRegex.placeholder).toEqual(PLACEHOLDER_USER_EXTERNAL_DEFAULT_TRUE); expect($userInternalRegex.readOnly).toBeFalsy(); - done(); }); }); }); diff --git a/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js b/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js index 52648d3ce00..ebf21c01324 100644 --- a/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js +++ b/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js @@ -26,7 +26,7 @@ describe('stop_jobs_modal.vue', () => { }); describe('onSubmit', () => { - it('stops jobs and redirects to overview page', (done) => { + it('stops jobs and redirects to overview page', async () => { const responseURL = `${TEST_HOST}/stop_jobs_modal.vue/jobs`; jest.spyOn(axios, 'post').mockImplementation((url) => { expect(url).toBe(props.url); @@ -37,29 +37,19 @@ describe('stop_jobs_modal.vue', () => { }); }); - vm.onSubmit() - .then(() => { - expect(redirectTo).toHaveBeenCalledWith(responseURL); - }) - .then(done) - .catch(done.fail); + await vm.onSubmit(); + expect(redirectTo).toHaveBeenCalledWith(responseURL); }); - it('displays error if stopping jobs failed', (done) => { + it('displays error if stopping jobs failed', async () => { const dummyError = new Error('stopping jobs failed'); jest.spyOn(axios, 'post').mockImplementation((url) => { expect(url).toBe(props.url); return Promise.reject(dummyError); }); - vm.onSubmit() - .then(done.fail) - .catch((error) => { - expect(error).toBe(dummyError); - expect(redirectTo).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + await expect(vm.onSubmit()).rejects.toEqual(dummyError); + expect(redirectTo).not.toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/pages/dashboard/todos/index/todos_spec.js b/spec/frontend/pages/dashboard/todos/index/todos_spec.js index ef295e7d1ba..ae53afa7fba 100644 --- a/spec/frontend/pages/dashboard/todos/index/todos_spec.js +++ b/spec/frontend/pages/dashboard/todos/index/todos_spec.js @@ -31,15 +31,17 @@ describe('Todos', () => { }); describe('goToTodoUrl', () => { - it('opens the todo url', (done) => { + it('opens the todo url', () => { const todoLink = todoItem.dataset.url; + let expectedUrl = null; visitUrl.mockImplementation((url) => { - expect(url).toEqual(todoLink); - done(); + expectedUrl = url; }); todoItem.click(); + + expect(expectedUrl).toEqual(todoLink); }); describe('meta click', () => { diff --git a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js index 6fb03fa28fe..43c48617800 100644 --- a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js +++ b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js @@ -137,6 +137,16 @@ describe('BulkImportsHistoryApp', () => { ); }); + it('renders correct url for destination group when relative_url is empty', async () => { + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); + createComponent({ shallow: false }); + await axios.waitForAll(); + + expect(wrapper.find('tbody tr a').attributes().href).toBe( + `/${DUMMY_RESPONSE[0].destination_namespace}/${DUMMY_RESPONSE[0].destination_name}`, + ); + }); + describe('details button', () => { beforeEach(() => { mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); diff --git a/spec/frontend/pages/import/history/components/import_error_details_spec.js b/spec/frontend/pages/import/history/components/import_error_details_spec.js new file mode 100644 index 00000000000..4ff3f0361cf --- /dev/null +++ b/spec/frontend/pages/import/history/components/import_error_details_spec.js @@ -0,0 +1,66 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import ImportErrorDetails from '~/pages/import/history/components/import_error_details.vue'; + +describe('ImportErrorDetails', () => { + const FAKE_ID = 5; + const API_URL = `/api/v4/projects/${FAKE_ID}`; + + let wrapper; + let mock; + + function createComponent({ shallow = true } = {}) { + const mountFn = shallow ? shallowMount : mount; + wrapper = mountFn(ImportErrorDetails, { + propsData: { + id: FAKE_ID, + }, + }); + } + + const originalApiVersion = gon.api_version; + beforeAll(() => { + gon.api_version = 'v4'; + }); + + afterAll(() => { + gon.api_version = originalApiVersion; + }); + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + describe('general behavior', () => { + it('renders loading state when loading', () => { + createComponent(); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + + it('renders import_error if it is available', async () => { + const FAKE_IMPORT_ERROR = 'IMPORT ERROR'; + mock.onGet(API_URL).reply(200, { import_error: FAKE_IMPORT_ERROR }); + createComponent(); + await axios.waitForAll(); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find('pre').text()).toBe(FAKE_IMPORT_ERROR); + }); + + it('renders default text if error is not available', async () => { + mock.onGet(API_URL).reply(200, { import_error: null }); + createComponent(); + await axios.waitForAll(); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find('pre').text()).toBe('No additional information provided.'); + }); + }); +}); diff --git a/spec/frontend/pages/import/history/components/import_history_app_spec.js b/spec/frontend/pages/import/history/components/import_history_app_spec.js new file mode 100644 index 00000000000..0d821b114cf --- /dev/null +++ b/spec/frontend/pages/import/history/components/import_history_app_spec.js @@ -0,0 +1,205 @@ +import { GlEmptyState, GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import ImportErrorDetails from '~/pages/import/history/components/import_error_details.vue'; +import ImportHistoryApp from '~/pages/import/history/components/import_history_app.vue'; +import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; + +describe('ImportHistoryApp', () => { + const API_URL = '/api/v4/projects.json'; + + const DEFAULT_HEADERS = { + 'x-page': 1, + 'x-per-page': 20, + 'x-next-page': 2, + 'x-total': 22, + 'x-total-pages': 2, + 'x-prev-page': null, + }; + const DUMMY_RESPONSE = [ + { + id: 1, + path_with_namespace: 'root/imported', + created_at: '2022-03-10T15:10:03.172Z', + import_url: null, + import_type: 'gitlab_project', + import_status: 'finished', + }, + { + id: 2, + name_with_namespace: 'Administrator / Dummy', + path_with_namespace: 'root/dummy', + created_at: '2022-03-09T11:23:04.974Z', + import_url: 'https://dummy.github/url', + import_type: 'github', + import_status: 'failed', + }, + { + id: 3, + name_with_namespace: 'Administrator / Dummy', + path_with_namespace: 'root/dummy2', + created_at: '2022-03-09T11:23:04.974Z', + import_url: 'git://non-http.url', + import_type: 'gi', + import_status: 'finished', + }, + ]; + let wrapper; + let mock; + + function createComponent({ shallow = true } = {}) { + const mountFn = shallow ? shallowMount : mount; + wrapper = mountFn(ImportHistoryApp, { + provide: { assets: { gitlabLogo: 'http://dummy.host' } }, + stubs: shallow ? { GlTable: { ...stubComponent(GlTable), props: ['items'] } } : {}, + }); + } + + const originalApiVersion = gon.api_version; + beforeAll(() => { + gon.api_version = 'v4'; + }); + + afterAll(() => { + gon.api_version = originalApiVersion; + }); + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + describe('general behavior', () => { + it('renders loading state when loading', () => { + createComponent(); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + + it('renders empty state when no data is available', async () => { + mock.onGet(API_URL).reply(200, [], DEFAULT_HEADERS); + createComponent(); + await axios.waitForAll(); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find(GlEmptyState).exists()).toBe(true); + }); + + it('renders table with data when history is available', async () => { + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); + createComponent(); + await axios.waitForAll(); + + const table = wrapper.find(GlTable); + expect(table.exists()).toBe(true); + expect(table.props().items).toStrictEqual(DUMMY_RESPONSE); + }); + + it('changes page when requested by pagination bar', async () => { + const NEW_PAGE = 4; + + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); + createComponent(); + await axios.waitForAll(); + mock.resetHistory(); + + const FAKE_NEXT_PAGE_REPLY = [ + { + id: 4, + path_with_namespace: 'root/some_other_project', + created_at: '2022-03-10T15:10:03.172Z', + import_url: null, + import_type: 'gitlab_project', + import_status: 'finished', + }, + ]; + + mock.onGet(API_URL).reply(200, FAKE_NEXT_PAGE_REPLY, DEFAULT_HEADERS); + + wrapper.findComponent(PaginationBar).vm.$emit('set-page', NEW_PAGE); + await axios.waitForAll(); + + expect(mock.history.get.length).toBe(1); + expect(mock.history.get[0].params).toStrictEqual(expect.objectContaining({ page: NEW_PAGE })); + expect(wrapper.find(GlTable).props().items).toStrictEqual(FAKE_NEXT_PAGE_REPLY); + }); + }); + + it('changes page size when requested by pagination bar', async () => { + const NEW_PAGE_SIZE = 4; + + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); + createComponent(); + await axios.waitForAll(); + mock.resetHistory(); + + wrapper.findComponent(PaginationBar).vm.$emit('set-page-size', NEW_PAGE_SIZE); + await axios.waitForAll(); + + expect(mock.history.get.length).toBe(1); + expect(mock.history.get[0].params).toStrictEqual( + expect.objectContaining({ per_page: NEW_PAGE_SIZE }), + ); + }); + + it('resets page to 1 when page size is changed', async () => { + const NEW_PAGE_SIZE = 4; + + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); + createComponent(); + await axios.waitForAll(); + wrapper.findComponent(PaginationBar).vm.$emit('set-page', 2); + await axios.waitForAll(); + mock.resetHistory(); + + wrapper.findComponent(PaginationBar).vm.$emit('set-page-size', NEW_PAGE_SIZE); + await axios.waitForAll(); + + expect(mock.history.get.length).toBe(1); + expect(mock.history.get[0].params).toStrictEqual( + expect.objectContaining({ per_page: NEW_PAGE_SIZE, page: 1 }), + ); + }); + + describe('details button', () => { + beforeEach(() => { + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); + createComponent({ shallow: false }); + return axios.waitForAll(); + }); + + it('renders details button if relevant item has failed', async () => { + expect( + extendedWrapper(wrapper.find('tbody').findAll('tr').at(1)).findByText('Details').exists(), + ).toBe(true); + }); + + it('does not render details button if relevant item does not failed', () => { + expect( + extendedWrapper(wrapper.find('tbody').findAll('tr').at(0)).findByText('Details').exists(), + ).toBe(false); + }); + + it('expands details when details button is clicked', async () => { + const ORIGINAL_ROW_INDEX = 1; + await extendedWrapper(wrapper.find('tbody').findAll('tr').at(ORIGINAL_ROW_INDEX)) + .findByText('Details') + .trigger('click'); + + const detailsRowContent = wrapper + .find('tbody') + .findAll('tr') + .at(ORIGINAL_ROW_INDEX + 1) + .findComponent(ImportErrorDetails); + + expect(detailsRowContent.exists()).toBe(true); + expect(detailsRowContent.props().id).toBe(DUMMY_RESPONSE[1].id); + }); + }); +}); diff --git a/spec/frontend/pages/profiles/show/emoji_menu_spec.js b/spec/frontend/pages/profiles/show/emoji_menu_spec.js index f35fb57aec7..fa6e7e51a60 100644 --- a/spec/frontend/pages/profiles/show/emoji_menu_spec.js +++ b/spec/frontend/pages/profiles/show/emoji_menu_spec.js @@ -46,22 +46,18 @@ describe('EmojiMenu', () => { const dummyEmoji = 'tropical_fish'; const dummyVotesBlock = () => $('<div />'); - it('calls selectEmojiCallback', (done) => { + it('calls selectEmojiCallback', async () => { expect(dummySelectEmojiCallback).not.toHaveBeenCalled(); - emojiMenu.addAward(dummyVotesBlock(), dummyAwardUrl, dummyEmoji, false, () => { - expect(dummySelectEmojiCallback).toHaveBeenCalledWith(dummyEmoji, dummyEmojiTag); - done(); - }); + await emojiMenu.addAward(dummyVotesBlock(), dummyAwardUrl, dummyEmoji, false); + expect(dummySelectEmojiCallback).toHaveBeenCalledWith(dummyEmoji, dummyEmojiTag); }); - it('does not make an axios request', (done) => { + it('does not make an axios request', async () => { jest.spyOn(axios, 'request').mockReturnValue(); - emojiMenu.addAward(dummyVotesBlock(), dummyAwardUrl, dummyEmoji, false, () => { - expect(axios.request).not.toHaveBeenCalled(); - done(); - }); + await emojiMenu.addAward(dummyVotesBlock(), dummyAwardUrl, dummyEmoji, false); + expect(axios.request).not.toHaveBeenCalled(); }); }); diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_section_card_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_section_card_spec.js.snap index 9e00ace761c..83feb621478 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_section_card_spec.js.snap +++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_section_card_spec.js.snap @@ -2,31 +2,26 @@ exports[`Learn GitLab Section Card renders correctly 1`] = ` <gl-card-stub - bodyclass="" - class="gl-pt-0 learn-gitlab-section-card" + bodyclass="gl-pt-0" + class="gl-pt-0 h-100" footerclass="" - headerclass="" + headerclass="gl-bg-white gl-border-0 gl-pb-0" > - <div - class="learn-gitlab-section-card-header" + <img + src="workspace.svg" + /> + + <h2 + class="gl-font-lg gl-mb-3" > - <img - src="workspace.svg" - /> - - <h2 - class="gl-font-lg gl-mb-3" - > - Set up your workspace - </h2> - - <p - class="gl-text-gray-700 gl-mb-6" - > - Complete these tasks first so you can enjoy GitLab's features to their fullest: - </p> - </div> + Set up your workspace + </h2> + <p + class="gl-text-gray-700 gl-mb-6" + > + Complete these tasks first so you can enjoy GitLab's features to their fullest: + </p> <learn-gitlab-section-link-stub action="userAdded" value="[object Object]" 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 62cf769cffd..269c7467c8b 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 @@ -51,170 +51,204 @@ exports[`Learn GitLab renders correctly 1`] = ` </div> <div - class="row row-cols-1 row-cols-md-3 gl-mt-5" + class="row" > <div - class="col gl-mb-6" + class="gl-mt-5 col-sm-12 col-mb-6 col-lg-4" > <div - class="gl-card gl-pt-0 learn-gitlab-section-card" + class="gl-card gl-pt-0 h-100" > - <!----> - <div - class="gl-card-body" + class="gl-card-header gl-bg-white gl-border-0 gl-pb-0" > - <div - class="learn-gitlab-section-card-header" + <img + src="workspace.svg" + /> + + <h2 + class="gl-font-lg gl-mb-3" > - <img - src="workspace.svg" - /> - - <h2 - class="gl-font-lg gl-mb-3" - > - Set up your workspace - </h2> - - <p - class="gl-text-gray-700 gl-mb-6" - > - Complete these tasks first so you can enjoy GitLab's features to their fullest: - </p> - </div> + Set up your workspace + </h2> + <p + class="gl-text-gray-700 gl-mb-6" + > + Complete these tasks first so you can enjoy GitLab's features to their fullest: + </p> + </div> + + <div + class="gl-card-body gl-pt-0" + > <div class="gl-mb-4" > - <span - class="gl-text-green-500" + <!----> + + <div + class="flex align-items-center" > - <svg - aria-hidden="true" - class="gl-icon s16" - data-testid="completed-icon" - role="img" + <span + class="gl-text-green-500" > - <use - href="#check-circle-filled" - /> - </svg> - - Invite your colleagues - - </span> - - <!----> + <svg + aria-hidden="true" + class="gl-icon s16" + data-testid="completed-icon" + role="img" + > + <use + href="#check-circle-filled" + /> + </svg> + + Invite your colleagues + + </span> + + <!----> + </div> </div> <div class="gl-mb-4" > - <span - class="gl-text-green-500" + <!----> + + <div + class="flex align-items-center" > - <svg - aria-hidden="true" - class="gl-icon s16" - data-testid="completed-icon" - role="img" + <span + class="gl-text-green-500" > - <use - href="#check-circle-filled" - /> - </svg> - - Create or import a repository - - </span> - - <!----> + <svg + aria-hidden="true" + class="gl-icon s16" + data-testid="completed-icon" + role="img" + > + <use + href="#check-circle-filled" + /> + </svg> + + Create or import a repository + + </span> + + <!----> + </div> </div> <div class="gl-mb-4" > - <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 + 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> </div> <div class="gl-mb-4" > - <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/" - target="_self" - > - - Start a free Ultimate trial - - </a> - <!----> + + <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/" + target="_self" + > + + Start a free Ultimate trial + + </a> + + <!----> + </div> </div> <div class="gl-mb-4" > - <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/" - target="_self" - > - - Add code owners - - </a> - - <span + <div class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only" > - - Trial only + Trial only - </span> + </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/" + target="_self" + > + + Add code owners + + </a> + + <!----> + </div> </div> <div class="gl-mb-4" > - <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/" - target="_self" - > - - Add merge request approval - - </a> - - <span + <div class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only" > - - Trial only + Trial only - </span> + </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/" + target="_self" + > + + Add merge request approval + + </a> + + <!----> + </div> </div> </div> @@ -222,71 +256,81 @@ exports[`Learn GitLab renders correctly 1`] = ` </div> </div> <div - class="col gl-mb-6" + class="gl-mt-5 col-sm-12 col-mb-6 col-lg-4" > <div - class="gl-card gl-pt-0 learn-gitlab-section-card" + class="gl-card gl-pt-0 h-100" > - <!----> - <div - class="gl-card-body" + class="gl-card-header gl-bg-white gl-border-0 gl-pb-0" > - <div - class="learn-gitlab-section-card-header" + <img + src="plan.svg" + /> + + <h2 + class="gl-font-lg gl-mb-3" > - <img - src="plan.svg" - /> - - <h2 - class="gl-font-lg gl-mb-3" - > - Plan and execute - </h2> - - <p - class="gl-text-gray-700 gl-mb-6" - > - Create a workflow for your new workspace, and learn how GitLab features work together: - </p> - </div> + Plan and execute + </h2> + <p + class="gl-text-gray-700 gl-mb-6" + > + Create a workflow for your new workspace, and learn how GitLab features work together: + </p> + </div> + + <div + class="gl-card-body gl-pt-0" + > <div class="gl-mb-4" > - <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 + 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> </div> <div class="gl-mb-4" > - <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 + 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> </div> </div> @@ -294,54 +338,58 @@ exports[`Learn GitLab renders correctly 1`] = ` </div> </div> <div - class="col gl-mb-6" + class="gl-mt-5 col-sm-12 col-mb-6 col-lg-4" > <div - class="gl-card gl-pt-0 learn-gitlab-section-card" + class="gl-card gl-pt-0 h-100" > - <!----> - <div - class="gl-card-body" + class="gl-card-header gl-bg-white gl-border-0 gl-pb-0" > - <div - class="learn-gitlab-section-card-header" + <img + src="deploy.svg" + /> + + <h2 + class="gl-font-lg gl-mb-3" > - <img - src="deploy.svg" - /> - - <h2 - class="gl-font-lg gl-mb-3" - > - Deploy - </h2> - - <p - class="gl-text-gray-700 gl-mb-6" - > - Use your new GitLab workflow to deploy your application, monitor its health, and keep it secure: - </p> - </div> + Deploy + </h2> + <p + class="gl-text-gray-700 gl-mb-6" + > + Use your new GitLab workflow to deploy your application, monitor its health, and keep it secure: + </p> + </div> + + <div + class="gl-card-body gl-pt-0" + > <div class="gl-mb-4" > - <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 + 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> </div> </div> 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 e21371123e8..b8ebf2a1430 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 @@ -1,4 +1,4 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import { stubExperiments } from 'helpers/experimentation_helper'; import { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper'; import eventHub from '~/invite_members/event_hub'; @@ -26,7 +26,7 @@ describe('Learn GitLab Section Link', () => { }); const createWrapper = (action = defaultAction, props = {}) => { - wrapper = shallowMount(LearnGitlabSectionLink, { + wrapper = mount(LearnGitlabSectionLink, { propsData: { action, value: { ...defaultProps, ...props } }, }); }; @@ -36,6 +36,8 @@ describe('Learn GitLab Section Link', () => { const findUncompletedLink = () => wrapper.find('[data-testid="uncompleted-learn-gitlab-link"]'); + const videoTutorialLink = () => wrapper.find('[data-testid="video-tutorial-link"]'); + it('renders no icon when not completed', () => { createWrapper(undefined, { completed: false }); @@ -130,4 +132,51 @@ describe('Learn GitLab Section Link', () => { unmockTracking(); }); }); + + describe('video_tutorials_continuous_onboarding experiment', () => { + describe('when control', () => { + beforeEach(() => { + stubExperiments({ video_tutorials_continuous_onboarding: 'control' }); + createWrapper('codeOwnersEnabled'); + }); + + it('renders no video link', () => { + expect(videoTutorialLink().exists()).toBe(false); + }); + }); + + describe('when candidate', () => { + beforeEach(() => { + stubExperiments({ video_tutorials_continuous_onboarding: 'candidate' }); + createWrapper('codeOwnersEnabled'); + }); + + it('renders video link with blank target', () => { + const videoLinkElement = videoTutorialLink(); + + expect(videoLinkElement.exists()).toBe(true); + expect(videoLinkElement.attributes('target')).toEqual('_blank'); + }); + + it('tracks the click', () => { + const trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); + + videoTutorialLink().trigger('click'); + + expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_video_link', { + label: 'Add code owners', + property: 'Growth::Conversion::Experiment::LearnGitLab', + context: { + data: { + experiment: 'video_tutorials_continuous_onboarding', + variant: 'candidate', + }, + schema: 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0', + }, + }); + + unmockTracking(); + }); + }); + }); }); diff --git a/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js b/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js index 0fffcf433a3..5771e1b88e8 100644 --- a/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js +++ b/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js @@ -3,15 +3,17 @@ import { shallowMount } from '@vue/test-utils'; import ProjectFeatureSetting from '~/pages/projects/shared/permissions/components/project_feature_setting.vue'; describe('Project Feature Settings', () => { + const defaultOptions = [ + [1, 1], + [2, 2], + [3, 3], + [4, 4], + [5, 5], + ]; + const defaultProps = { name: 'Test', - options: [ - [1, 1], - [2, 2], - [3, 3], - [4, 4], - [5, 5], - ], + options: defaultOptions, value: 1, disabledInput: false, showToggle: true, @@ -110,15 +112,25 @@ describe('Project Feature Settings', () => { }, ); - it('should emit the change when a new option is selected', () => { + it('should emit the change when a new option is selected', async () => { wrapper = mountComponent(); expect(wrapper.emitted('change')).toBeUndefined(); - wrapper.findAll('option').at(1).trigger('change'); + await wrapper.findAll('option').at(1).setSelected(); expect(wrapper.emitted('change')).toHaveLength(1); expect(wrapper.emitted('change')[0]).toEqual([2]); }); + + it('value of select matches prop `value` if options are modified', async () => { + wrapper = mountComponent(); + + await wrapper.setProps({ value: 0, options: [[0, 0]] }); + expect(wrapper.find('select').element.selectedIndex).toBe(0); + + await wrapper.setProps({ value: 2, options: defaultOptions }); + expect(wrapper.find('select').element.selectedIndex).toBe(1); + }); }); }); 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 305dce51971..30d5f89d2f6 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 @@ -1,6 +1,6 @@ import { GlSprintf, GlToggle } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; -import projectFeatureSetting from '~/pages/projects/shared/permissions/components/project_feature_setting.vue'; +import ProjectFeatureSetting from '~/pages/projects/shared/permissions/components/project_feature_setting.vue'; import settingsPanel from '~/pages/projects/shared/permissions/components/settings_panel.vue'; import { featureAccessLevel, @@ -21,6 +21,7 @@ const defaultProps = { wikiAccessLevel: 20, snippetsAccessLevel: 20, operationsAccessLevel: 20, + metricsDashboardAccessLevel: 20, pagesAccessLevel: 10, analyticsAccessLevel: 20, containerRegistryAccessLevel: 20, @@ -75,7 +76,7 @@ describe('Settings Panel', () => { const findLFSFeatureToggle = () => findLFSSettingsRow().find(GlToggle); const findRepositoryFeatureProjectRow = () => wrapper.find({ ref: 'repository-settings' }); const findRepositoryFeatureSetting = () => - findRepositoryFeatureProjectRow().find(projectFeatureSetting); + findRepositoryFeatureProjectRow().find(ProjectFeatureSetting); const findProjectVisibilitySettings = () => wrapper.find({ ref: 'project-visibility-settings' }); const findIssuesSettingsRow = () => wrapper.find({ ref: 'issues-settings' }); const findAnalyticsRow = () => wrapper.find({ ref: 'analytics-settings' }); @@ -106,7 +107,11 @@ describe('Settings Panel', () => { 'input[name="project[project_setting_attributes][warn_about_potentially_unwanted_characters]"]', ); const findMetricsVisibilitySettings = () => wrapper.find({ ref: 'metrics-visibility-settings' }); + const findMetricsVisibilityInput = () => + findMetricsVisibilitySettings().findComponent(ProjectFeatureSetting); const findOperationsSettings = () => wrapper.find({ ref: 'operations-settings' }); + const findOperationsVisibilityInput = () => + findOperationsSettings().findComponent(ProjectFeatureSetting); const findConfirmDangerButton = () => wrapper.findComponent(ConfirmDanger); afterEach(() => { @@ -595,7 +600,7 @@ describe('Settings Panel', () => { }); describe('Metrics dashboard', () => { - it('should show the metrics dashboard access toggle', () => { + it('should show the metrics dashboard access select', () => { wrapper = mountComponent(); expect(findMetricsVisibilitySettings().exists()).toBe(true); @@ -610,23 +615,51 @@ describe('Settings Panel', () => { }); it.each` - scenario | selectedOption | selectedOptionLabel - ${{ currentSettings: { visibilityLevel: visibilityOptions.PRIVATE } }} | ${String(featureAccessLevel.PROJECT_MEMBERS)} | ${'Only Project Members'} - ${{ currentSettings: { operationsAccessLevel: featureAccessLevel.NOT_ENABLED } }} | ${String(featureAccessLevel.NOT_ENABLED)} | ${'Enable feature to choose access level'} + before | after + ${featureAccessLevel.NOT_ENABLED} | ${featureAccessLevel.EVERYONE} + ${featureAccessLevel.NOT_ENABLED} | ${featureAccessLevel.PROJECT_MEMBERS} + ${featureAccessLevel.EVERYONE} | ${featureAccessLevel.PROJECT_MEMBERS} + ${featureAccessLevel.EVERYONE} | ${featureAccessLevel.NOT_ENABLED} + ${featureAccessLevel.PROJECT_MEMBERS} | ${featureAccessLevel.NOT_ENABLED} `( - 'should disable the metrics visibility dropdown when #scenario', - ({ scenario, selectedOption, selectedOptionLabel }) => { - wrapper = mountComponent(scenario, mount); + 'when updating Operations Settings access level from `$before` to `$after`, Metric Dashboard access is updated to `$after` as well', + async ({ before, after }) => { + wrapper = mountComponent({ + currentSettings: { operationsAccessLevel: before, metricsDashboardAccessLevel: before }, + }); - const select = findMetricsVisibilitySettings().find('select'); - const option = select.find('option'); + await findOperationsVisibilityInput().vm.$emit('change', after); - expect(select.attributes('disabled')).toBe('disabled'); - expect(select.element.value).toBe(selectedOption); - expect(option.attributes('value')).toBe(selectedOption); - expect(option.text()).toBe(selectedOptionLabel); + expect(findMetricsVisibilityInput().props('value')).toBe(after); }, ); + + it('when updating Operations Settings access level from `10` to `20`, Metric Dashboard access is not increased', async () => { + wrapper = mountComponent({ + currentSettings: { + operationsAccessLevel: featureAccessLevel.PROJECT_MEMBERS, + metricsDashboardAccessLevel: featureAccessLevel.PROJECT_MEMBERS, + }, + }); + + await findOperationsVisibilityInput().vm.$emit('change', featureAccessLevel.EVERYONE); + + expect(findMetricsVisibilityInput().props('value')).toBe(featureAccessLevel.PROJECT_MEMBERS); + }); + + it('should reduce Metrics visibility level when visibility is set to private', async () => { + wrapper = mountComponent({ + currentSettings: { + visibilityLevel: visibilityOptions.PUBLIC, + operationsAccessLevel: featureAccessLevel.EVERYONE, + metricsDashboardAccessLevel: featureAccessLevel.EVERYONE, + }, + }); + + await findProjectVisibilityLevelInput().setValue(visibilityOptions.PRIVATE); + + expect(findMetricsVisibilityInput().props('value')).toBe(featureAccessLevel.PROJECT_MEMBERS); + }); }); describe('Analytics', () => { diff --git a/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js new file mode 100644 index 00000000000..365bb878485 --- /dev/null +++ b/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js @@ -0,0 +1,97 @@ +import { GlSkeletonLoader, GlAlert } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import WikiContent from '~/pages/shared/wikis/components/wiki_content.vue'; +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'; + +jest.mock('~/pages/shared/wikis/render_gfm_facade'); + +describe('pages/shared/wikis/components/wiki_content', () => { + const PATH = '/test'; + let wrapper; + let mock; + + function buildWrapper(propsData = {}) { + wrapper = shallowMount(WikiContent, { + propsData: { getWikiContentUrl: PATH, ...propsData }, + stubs: { + GlSkeletonLoader, + GlAlert, + }, + }); + } + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findGlAlert = () => wrapper.findComponent(GlAlert); + const findGlSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findContent = () => wrapper.find('[data-testid="wiki_page_content"]'); + + describe('when loading content', () => { + beforeEach(() => { + buildWrapper(); + }); + + it('renders skeleton loader', () => { + expect(findGlSkeletonLoader().exists()).toBe(true); + }); + + it('does not render content container or error alert', () => { + expect(findGlAlert().exists()).toBe(false); + expect(findContent().exists()).toBe(false); + }); + }); + + describe('when content loads successfully', () => { + const content = 'content'; + + beforeEach(() => { + mock.onGet(PATH, { params: { render_html: true } }).replyOnce(httpStatus.OK, { content }); + buildWrapper(); + return waitForPromises(); + }); + + it('renders content container', () => { + expect(findContent().text()).toBe(content); + }); + + it('does not render skeleton loader or error alert', () => { + expect(findGlAlert().exists()).toBe(false); + expect(findGlSkeletonLoader().exists()).toBe(false); + }); + + it('calls renderGFM after nextTick', async () => { + await nextTick(); + + expect(renderGFM).toHaveBeenCalledWith(wrapper.element); + }); + }); + + describe('when loading content fails', () => { + beforeEach(() => { + mock.onGet(PATH).replyOnce(httpStatus.INTERNAL_SERVER_ERROR, ''); + buildWrapper(); + return waitForPromises(); + }); + + it('renders error alert', () => { + expect(findGlAlert().exists()).toBe(true); + }); + + it('does not render skeleton loader or content container', () => { + expect(findContent().exists()).toBe(false); + expect(findGlSkeletonLoader().exists()).toBe(false); + }); + }); +}); 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 e118a35804f..d7f8dc3c98e 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js @@ -1,5 +1,5 @@ import { nextTick } from 'vue'; -import { GlLoadingIcon, GlModal, GlAlert, GlButton } from '@gitlab/ui'; +import { GlAlert, GlButton } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; @@ -32,11 +32,7 @@ describe('WikiForm', () => { const findMessage = () => wrapper.find('#wiki_message'); const findSubmitButton = () => wrapper.findByTestId('wiki-submit-button'); const findCancelButton = () => wrapper.findByTestId('wiki-cancel-button'); - const findUseNewEditorButton = () => wrapper.findByText('Use the new editor'); const findToggleEditingModeButton = () => wrapper.findByTestId('toggle-editing-mode-button'); - const findDismissContentEditorAlertButton = () => wrapper.findByText('Try this later'); - const findSwitchToOldEditorButton = () => - wrapper.findByRole('button', { name: 'Switch me back to the classic editor.' }); const findTitleHelpLink = () => wrapper.findByText('Learn more.'); const findMarkdownHelpLink = () => wrapper.findByTestId('wiki-markdown-help-link'); const findContentEditor = () => wrapper.findComponent(ContentEditor); @@ -293,27 +289,21 @@ describe('WikiForm', () => { ); }); - describe('when wikiSwitchBetweenContentEditorRawMarkdown feature flag is not enabled', () => { + describe('toggle editing mode control', () => { beforeEach(() => { - createWrapper({ - glFeatures: { wikiSwitchBetweenContentEditorRawMarkdown: false }, - }); - }); - - it('hides toggle editing mode button', () => { - expect(findToggleEditingModeButton().exists()).toBe(false); - }); - }); - - describe('when wikiSwitchBetweenContentEditorRawMarkdown feature flag is enabled', () => { - beforeEach(() => { - createWrapper({ - glFeatures: { wikiSwitchBetweenContentEditorRawMarkdown: true }, - }); + createWrapper(); }); - it('hides gl-alert containing "use new editor" button', () => { - expect(findUseNewEditorButton().exists()).toBe(false); + it.each` + format | enabled | action + ${'markdown'} | ${true} | ${'displays'} + ${'rdoc'} | ${false} | ${'hides'} + ${'asciidoc'} | ${false} | ${'hides'} + ${'org'} | ${false} | ${'hides'} + `('$action toggle editing mode button when format is $format', async ({ format, enabled }) => { + await setFormat(format); + + expect(findToggleEditingModeButton().exists()).toBe(enabled); }); it('displays toggle editing mode button', () => { @@ -326,8 +316,8 @@ describe('WikiForm', () => { }); describe('when clicking the toggle editing mode button', () => { - beforeEach(() => { - findToggleEditingModeButton().vm.$emit('click'); + beforeEach(async () => { + await findToggleEditingModeButton().trigger('click'); }); it('hides the classic editor', () => { @@ -343,17 +333,13 @@ describe('WikiForm', () => { describe('when content editor is active', () => { let mockContentEditor; - beforeEach(() => { + beforeEach(async () => { mockContentEditor = { getSerializedContent: jest.fn(), setSerializedContent: jest.fn(), }; - findToggleEditingModeButton().vm.$emit('click'); - }); - - it('hides switch to old editor button', () => { - expect(findSwitchToOldEditorButton().exists()).toBe(false); + await findToggleEditingModeButton().trigger('click'); }); it('displays "Edit source" label in the toggle editing mode button', () => { @@ -363,13 +349,13 @@ describe('WikiForm', () => { describe('when clicking the toggle editing mode button', () => { const contentEditorFakeSerializedContent = 'fake content'; - beforeEach(() => { + beforeEach(async () => { mockContentEditor.getSerializedContent.mockReturnValueOnce( contentEditorFakeSerializedContent, ); findContentEditor().vm.$emit('initialized', mockContentEditor); - findToggleEditingModeButton().vm.$emit('click'); + await findToggleEditingModeButton().trigger('click'); }); it('hides the content editor', () => { @@ -388,75 +374,12 @@ describe('WikiForm', () => { }); describe('wiki content editor', () => { - it.each` - format | buttonExists - ${'markdown'} | ${true} - ${'rdoc'} | ${false} - `( - 'gl-alert containing "use new editor" button exists: $buttonExists if format is $format', - async ({ format, buttonExists }) => { - createWrapper(); - - await setFormat(format); - - expect(findUseNewEditorButton().exists()).toBe(buttonExists); - }, - ); - - it('gl-alert containing "use new editor" button is dismissed on clicking dismiss button', async () => { - createWrapper(); - - await findDismissContentEditorAlertButton().trigger('click'); - - expect(findUseNewEditorButton().exists()).toBe(false); - }); - - const assertOldEditorIsVisible = () => { - expect(findContentEditor().exists()).toBe(false); - expect(findClassicEditor().exists()).toBe(true); - expect(findSubmitButton().props('disabled')).toBe(false); - - expect(wrapper.text()).not.toContain( - "Switching will discard any changes you've made in the new editor.", - ); - expect(wrapper.text()).not.toContain( - "This editor is in beta and may not display the page's contents properly.", - ); - }; - - it('shows classic editor by default', () => { - createWrapper({ persisted: true }); - - assertOldEditorIsVisible(); - }); - - describe('switch format to rdoc', () => { - beforeEach(async () => { - createWrapper({ persisted: true }); - - await setFormat('rdoc'); - }); - - it('continues to show the classic editor', assertOldEditorIsVisible); - - describe('switch format back to markdown', () => { - beforeEach(async () => { - await setFormat('markdown'); - }); - - it( - 'still shows the classic editor and does not automatically switch to the content editor ', - assertOldEditorIsVisible, - ); - }); - }); - describe('clicking "use new editor": editor fails to load', () => { beforeEach(async () => { createWrapper({ mountFn: mount }); mock.onPost(/preview-markdown/).reply(400); - await findUseNewEditorButton().trigger('click'); + await findToggleEditingModeButton().trigger('click'); // try waiting for content editor to load (but it will never actually load) await waitForPromises(); @@ -466,14 +389,14 @@ describe('WikiForm', () => { expect(findSubmitButton().props('disabled')).toBe(true); }); - describe('clicking "switch to classic editor"', () => { + describe('toggling editing modes to the classic editor', () => { beforeEach(() => { - return findSwitchToOldEditorButton().trigger('click'); + return findToggleEditingModeButton().trigger('click'); }); - it('switches to classic editor directly without showing a modal', () => { - expect(wrapper.findComponent(ContentEditor).exists()).toBe(false); - expect(wrapper.findComponent(MarkdownField).exists()).toBe(true); + it('switches to classic editor', () => { + expect(findContentEditor().exists()).toBe(false); + expect(findClassicEditor().exists()).toBe(true); }); }); }); @@ -484,31 +407,15 @@ describe('WikiForm', () => { mock.onPost(/preview-markdown/).reply(200, { body: '<p>hello <strong>world</strong></p>' }); - await findUseNewEditorButton().trigger('click'); - }); - - it('shows a tip to send feedback', () => { - expect(wrapper.text()).toContain('Tell us your experiences with the new Markdown editor'); - }); - - it('shows warnings that the rich text editor is in beta and may not work properly', () => { - expect(wrapper.text()).toContain( - "This editor is in beta and may not display the page's contents properly.", - ); + await findToggleEditingModeButton().trigger('click'); + await waitForPromises(); }); it('shows the rich text editor when loading finishes', async () => { - // wait for content editor to load - await waitForPromises(); - - expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false); - expect(wrapper.findComponent(ContentEditor).exists()).toBe(true); + expect(findContentEditor().exists()).toBe(true); }); it('sends tracking event when editor loads', async () => { - // wait for content editor to load - await waitForPromises(); - expect(trackingSpy).toHaveBeenCalledWith(undefined, CONTENT_EDITOR_LOADED_ACTION, { label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, }); @@ -564,49 +471,6 @@ describe('WikiForm', () => { expect(findContent().element.value).toBe('hello **world**'); }); }); - - describe('clicking "switch to classic editor"', () => { - let modal; - - beforeEach(async () => { - modal = wrapper.findComponent(GlModal); - jest.spyOn(modal.vm, 'show'); - - findSwitchToOldEditorButton().trigger('click'); - }); - - it('shows a modal confirming the change', () => { - expect(modal.vm.show).toHaveBeenCalled(); - }); - - describe('confirming "switch to classic editor" in the modal', () => { - beforeEach(async () => { - wrapper.vm.contentEditor.tiptapEditor.commands.setContent( - '<p>hello __world__ from content editor</p>', - true, - ); - - wrapper.findComponent(GlModal).vm.$emit('primary'); - - await nextTick(); - }); - - it('switches to classic editor', () => { - expect(wrapper.findComponent(ContentEditor).exists()).toBe(false); - expect(wrapper.findComponent(MarkdownField).exists()).toBe(true); - }); - - it('does not show a warning about content editor', () => { - expect(wrapper.text()).not.toContain( - "This editor is in beta and may not display the page's contents properly.", - ); - }); - - it('the classic editor retains its old value and does not use the content from the content editor', () => { - expect(findContent().element.value).toBe(' My page content '); - }); - }); - }); }); }); }); diff --git a/spec/frontend/pdf/page_spec.js b/spec/frontend/pdf/page_spec.js index 4e0a6f78b63..07a7f1bb2ff 100644 --- a/spec/frontend/pdf/page_spec.js +++ b/spec/frontend/pdf/page_spec.js @@ -1,4 +1,4 @@ -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import mountComponent from 'helpers/vue_mount_component_helper'; import PageComponent from '~/pdf/page/index.vue'; @@ -14,8 +14,7 @@ describe('Page component', () => { vm.$destroy(); }); - it('renders the page when mounting', (done) => { - const promise = Promise.resolve(); + it('renders the page when mounting', async () => { const testPage = { render: jest.fn().mockReturnValue({ promise: Promise.resolve() }), getViewport: jest.fn().mockReturnValue({}), @@ -28,12 +27,9 @@ describe('Page component', () => { expect(vm.rendering).toBe(true); - promise - .then(() => { - expect(testPage.render).toHaveBeenCalledWith(vm.renderContext); - expect(vm.rendering).toBe(false); - }) - .then(done) - .catch(done.fail); + await nextTick(); + + expect(testPage.render).toHaveBeenCalledWith(vm.renderContext); + expect(vm.rendering).toBe(false); }); }); diff --git a/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js b/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js index ba06f113120..33b53bf6a56 100644 --- a/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js +++ b/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js @@ -1,146 +1,27 @@ -import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { useLocalStorageSpy } from 'helpers/local_storage_helper'; -import FirstPipelineCard from '~/pipeline_editor/components/drawer/cards/first_pipeline_card.vue'; -import GettingStartedCard from '~/pipeline_editor/components/drawer/cards/getting_started_card.vue'; -import PipelineConfigReferenceCard from '~/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue'; -import VisualizeAndLintCard from '~/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue'; +import { GlDrawer } from '@gitlab/ui'; import PipelineEditorDrawer from '~/pipeline_editor/components/drawer/pipeline_editor_drawer.vue'; -import { DRAWER_EXPANDED_KEY } from '~/pipeline_editor/constants'; -import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; describe('Pipeline editor drawer', () => { - useLocalStorageSpy(); - let wrapper; + const findDrawer = () => wrapper.findComponent(GlDrawer); + const createComponent = () => { - wrapper = shallowMount(PipelineEditorDrawer, { - stubs: { LocalStorageSync }, - }); + wrapper = shallowMount(PipelineEditorDrawer); }; - const findFirstPipelineCard = () => wrapper.findComponent(FirstPipelineCard); - const findGettingStartedCard = () => wrapper.findComponent(GettingStartedCard); - const findPipelineConfigReferenceCard = () => wrapper.findComponent(PipelineConfigReferenceCard); - const findToggleBtn = () => wrapper.findComponent(GlButton); - const findVisualizeAndLintCard = () => wrapper.findComponent(VisualizeAndLintCard); - - const findArrowIcon = () => wrapper.find('[data-testid="toggle-icon"]'); - const findCollapseText = () => wrapper.find('[data-testid="collapse-text"]'); - const findDrawerContent = () => wrapper.find('[data-testid="drawer-content"]'); - - const clickToggleBtn = async () => findToggleBtn().vm.$emit('click'); - - const originalObjects = []; - - beforeEach(() => { - originalObjects.push(window.gon, window.gl); - }); - afterEach(() => { wrapper.destroy(); - localStorage.clear(); - [window.gon, window.gl] = originalObjects; - }); - - describe('default expanded state', () => { - it('sets the drawer to be closed by default', async () => { - createComponent(); - expect(findDrawerContent().exists()).toBe(false); - }); - }); - - describe('when the drawer is collapsed', () => { - beforeEach(async () => { - createComponent(); - }); - - it('shows the left facing arrow icon', () => { - expect(findArrowIcon().props('name')).toBe('chevron-double-lg-left'); - }); - - it('does not show the collapse text', () => { - expect(findCollapseText().exists()).toBe(false); - }); - - it('does not show the drawer content', () => { - expect(findDrawerContent().exists()).toBe(false); - }); - - it('can open the drawer by clicking on the toggle button', async () => { - expect(findDrawerContent().exists()).toBe(false); - - await clickToggleBtn(); - - expect(findDrawerContent().exists()).toBe(true); - }); - }); - - describe('when the drawer is expanded', () => { - beforeEach(async () => { - createComponent(); - await clickToggleBtn(); - }); - - it('shows the right facing arrow icon', () => { - expect(findArrowIcon().props('name')).toBe('chevron-double-lg-right'); - }); - - it('shows the collapse text', () => { - expect(findCollapseText().exists()).toBe(true); - }); - - it('shows the drawer content', () => { - expect(findDrawerContent().exists()).toBe(true); - }); - - it('shows all the introduction cards', () => { - expect(findFirstPipelineCard().exists()).toBe(true); - expect(findGettingStartedCard().exists()).toBe(true); - expect(findPipelineConfigReferenceCard().exists()).toBe(true); - expect(findVisualizeAndLintCard().exists()).toBe(true); - }); - - it('can close the drawer by clicking on the toggle button', async () => { - expect(findDrawerContent().exists()).toBe(true); - - await clickToggleBtn(); - - expect(findDrawerContent().exists()).toBe(false); - }); }); - describe('local storage', () => { - it('saves the drawer expanded value to local storage', async () => { - localStorage.setItem(DRAWER_EXPANDED_KEY, 'false'); - - createComponent(); - await clickToggleBtn(); - - expect(localStorage.setItem.mock.calls).toEqual([ - [DRAWER_EXPANDED_KEY, 'false'], - [DRAWER_EXPANDED_KEY, 'true'], - ]); - }); - - it('loads the drawer collapsed when local storage is set to `false`, ', async () => { - localStorage.setItem(DRAWER_EXPANDED_KEY, false); - createComponent(); - - await nextTick(); - - expect(findDrawerContent().exists()).toBe(false); - }); + it('emits close event when closing the drawer', () => { + createComponent(); - it('loads the drawer expanded when local storage is set to `true`, ', async () => { - localStorage.setItem(DRAWER_EXPANDED_KEY, true); - createComponent(); + expect(wrapper.emitted('close-drawer')).toBeUndefined(); - await nextTick(); + findDrawer().vm.$emit('close'); - expect(findDrawerContent().exists()).toBe(true); - }); + expect(wrapper.emitted('close-drawer')).toHaveLength(1); }); }); diff --git a/spec/frontend/pipeline_editor/components/editor/ci_editor_header_spec.js b/spec/frontend/pipeline_editor/components/editor/ci_editor_header_spec.js index 3ee53d4a055..8f50325295e 100644 --- a/spec/frontend/pipeline_editor/components/editor/ci_editor_header_spec.js +++ b/spec/frontend/pipeline_editor/components/editor/ci_editor_header_spec.js @@ -1,5 +1,5 @@ -import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import CiEditorHeader from '~/pipeline_editor/components/editor/ci_editor_header.vue'; import { @@ -11,11 +11,18 @@ describe('CI Editor Header', () => { let wrapper; let trackingSpy = null; - const createComponent = () => { - wrapper = shallowMount(CiEditorHeader, {}); + const createComponent = ({ showDrawer = false } = {}) => { + wrapper = extendedWrapper( + shallowMount(CiEditorHeader, { + propsData: { + showDrawer, + }, + }), + ); }; - const findLinkBtn = () => wrapper.findComponent(GlButton); + const findLinkBtn = () => wrapper.findByTestId('template-repo-link'); + const findHelpBtn = () => wrapper.findByTestId('drawer-toggle'); afterEach(() => { wrapper.destroy(); @@ -50,4 +57,42 @@ describe('CI Editor Header', () => { }); }); }); + + describe('help button', () => { + beforeEach(() => { + createComponent(); + }); + + it('finds the help button', () => { + expect(findHelpBtn().exists()).toBe(true); + }); + + it('has the information-o icon', () => { + expect(findHelpBtn().props('icon')).toBe('information-o'); + }); + + describe('when pipeline editor drawer is closed', () => { + it('emits open drawer event when clicked', () => { + createComponent({ showDrawer: false }); + + expect(wrapper.emitted('open-drawer')).toBeUndefined(); + + findHelpBtn().vm.$emit('click'); + + expect(wrapper.emitted('open-drawer')).toHaveLength(1); + }); + }); + + describe('when pipeline editor drawer is open', () => { + it('emits close drawer event when clicked', () => { + createComponent({ showDrawer: true }); + + expect(wrapper.emitted('close-drawer')).toBeUndefined(); + + findHelpBtn().vm.$emit('click'); + + expect(wrapper.emitted('close-drawer')).toHaveLength(1); + }); + }); + }); }); 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 fee52db9b64..6dffb7e5470 100644 --- a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js +++ b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js @@ -40,6 +40,7 @@ describe('Pipeline editor tabs component', () => { ciConfigData: mockLintResponse, ciFileContent: mockCiYml, isNewCiConfigFile: true, + showDrawer: false, ...props, }, data() { diff --git a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js index 6f969546171..98e2c17967c 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js @@ -1,6 +1,8 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; -import { GlModal } from '@gitlab/ui'; +import { GlButton, GlDrawer, GlModal } from '@gitlab/ui'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import CiEditorHeader from '~/pipeline_editor/components/editor/ci_editor_header.vue'; import CommitSection from '~/pipeline_editor/components/commit/commit_section.vue'; import PipelineEditorDrawer from '~/pipeline_editor/components/drawer/pipeline_editor_drawer.vue'; import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; @@ -18,24 +20,26 @@ describe('Pipeline editor home wrapper', () => { let wrapper; const createComponent = ({ props = {}, glFeatures = {}, data = {}, stubs = {} } = {}) => { - wrapper = shallowMount(PipelineEditorHome, { - data: () => data, - propsData: { - ciConfigData: mockLintResponse, - ciFileContent: mockCiYml, - isCiConfigDataLoading: false, - isNewCiConfigFile: false, - ...props, - }, - provide: { - projectFullPath: '', - totalBranches: 19, - glFeatures: { - ...glFeatures, + wrapper = extendedWrapper( + shallowMount(PipelineEditorHome, { + data: () => data, + propsData: { + ciConfigData: mockLintResponse, + ciFileContent: mockCiYml, + isCiConfigDataLoading: false, + isNewCiConfigFile: false, + ...props, }, - }, - stubs, - }); + provide: { + projectFullPath: '', + totalBranches: 19, + glFeatures: { + ...glFeatures, + }, + }, + stubs, + }), + ); }; const findBranchSwitcher = () => wrapper.findComponent(BranchSwitcher); @@ -45,6 +49,7 @@ describe('Pipeline editor home wrapper', () => { const findPipelineEditorDrawer = () => wrapper.findComponent(PipelineEditorDrawer); const findPipelineEditorHeader = () => wrapper.findComponent(PipelineEditorHeader); const findPipelineEditorTabs = () => wrapper.findComponent(PipelineEditorTabs); + const findHelpBtn = () => wrapper.findByTestId('drawer-toggle'); afterEach(() => { wrapper.destroy(); @@ -70,10 +75,6 @@ describe('Pipeline editor home wrapper', () => { it('shows the commit section by default', () => { expect(findCommitSection().exists()).toBe(true); }); - - it('show the pipeline drawer', () => { - expect(findPipelineEditorDrawer().exists()).toBe(true); - }); }); describe('modal when switching branch', () => { @@ -175,4 +176,58 @@ describe('Pipeline editor home wrapper', () => { }); }); }); + + describe('help drawer', () => { + const clickHelpBtn = async () => { + findHelpBtn().vm.$emit('click'); + await nextTick(); + }; + + it('hides the drawer by default', () => { + createComponent(); + + expect(findPipelineEditorDrawer().props('isVisible')).toBe(false); + }); + + it('toggles the drawer on button click', async () => { + createComponent({ + stubs: { + CiEditorHeader, + GlButton, + GlDrawer, + PipelineEditorTabs, + PipelineEditorDrawer, + }, + }); + + await clickHelpBtn(); + + expect(findPipelineEditorDrawer().props('isVisible')).toBe(true); + + await clickHelpBtn(); + + expect(findPipelineEditorDrawer().props('isVisible')).toBe(false); + }); + + it("closes the drawer through the drawer's close button", async () => { + createComponent({ + stubs: { + CiEditorHeader, + GlButton, + GlDrawer, + PipelineEditorTabs, + PipelineEditorDrawer, + }, + }); + + await clickHelpBtn(); + + expect(findPipelineEditorDrawer().props('isVisible')).toBe(true); + + findPipelineEditorDrawer().find(GlDrawer).vm.$emit('close'); + await nextTick(); + + expect(findPipelineEditorDrawer().props('isVisible')).toBe(false); + }); + }); }); diff --git a/spec/frontend/pipeline_wizard/components/wrapper_spec.js b/spec/frontend/pipeline_wizard/components/wrapper_spec.js index bd1679baf48..357a9d21723 100644 --- a/spec/frontend/pipeline_wizard/components/wrapper_spec.js +++ b/spec/frontend/pipeline_wizard/components/wrapper_spec.js @@ -1,21 +1,26 @@ import { Document, parseDocument } from 'yaml'; import { GlProgressBar } from '@gitlab/ui'; import { nextTick } from 'vue'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import PipelineWizardWrapper, { i18n } from '~/pipeline_wizard/components/wrapper.vue'; import WizardStep from '~/pipeline_wizard/components/step.vue'; import CommitStep from '~/pipeline_wizard/components/commit.vue'; import YamlEditor from '~/pipeline_wizard/components/editor.vue'; import { sprintf } from '~/locale'; -import { steps as stepsYaml } from '../mock/yaml'; +import { + steps as stepsYaml, + compiledScenario1, + compiledScenario2, + compiledScenario3, +} from '../mock/yaml'; describe('Pipeline Wizard - wrapper.vue', () => { let wrapper; const steps = parseDocument(stepsYaml).toJS(); const getAsYamlNode = (value) => new Document(value).contents; - const createComponent = (props = {}) => { - wrapper = shallowMountExtended(PipelineWizardWrapper, { + const createComponent = (props = {}, mountFn = shallowMountExtended) => { + wrapper = mountFn(PipelineWizardWrapper, { propsData: { projectPath: '/user/repo', defaultBranch: 'main', @@ -23,13 +28,21 @@ describe('Pipeline Wizard - wrapper.vue', () => { steps: getAsYamlNode(steps), ...props, }, + stubs: { + CommitStep: true, + }, }); }; const getEditorContent = () => { - return wrapper.getComponent(YamlEditor).attributes().doc.toString(); + return wrapper.getComponent(YamlEditor).props().doc.toString(); }; - const getStepWrapper = () => wrapper.getComponent(WizardStep); + const getStepWrapper = () => + wrapper.findAllComponents(WizardStep).wrappers.find((w) => w.isVisible()); const getGlProgressBarWrapper = () => wrapper.getComponent(GlProgressBar); + const findFirstVisibleStep = () => + wrapper.findAllComponents('[data-testid="step"]').wrappers.find((w) => w.isVisible()); + const findFirstInputFieldForTarget = (target) => + wrapper.find(`[data-input-target="${target}"]`).find('input'); describe('display', () => { afterEach(() => { @@ -118,8 +131,9 @@ describe('Pipeline Wizard - wrapper.vue', () => { }) => { beforeAll(async () => { createComponent(); + for (const emittedValue of navigationEventChain) { - wrapper.findComponent({ ref: 'step' }).vm.$emit(emittedValue); + findFirstVisibleStep().vm.$emit(emittedValue); // We have to wait for the next step to be mounted // before we can emit the next event, so we have to await // inside the loop. @@ -134,11 +148,11 @@ describe('Pipeline Wizard - wrapper.vue', () => { if (expectCommitStepShown) { it('does not show the step wrapper', async () => { - expect(wrapper.findComponent(WizardStep).exists()).toBe(false); + expect(wrapper.findComponent(WizardStep).isVisible()).toBe(false); }); it('shows the commit step page', () => { - expect(wrapper.findComponent(CommitStep).exists()).toBe(true); + expect(wrapper.findComponent(CommitStep).isVisible()).toBe(true); }); } else { it('passes the correct step config to the step component', async () => { @@ -146,7 +160,7 @@ describe('Pipeline Wizard - wrapper.vue', () => { }); it('does not show the commit step page', () => { - expect(wrapper.findComponent(CommitStep).exists()).toBe(false); + expect(wrapper.findComponent(CommitStep).isVisible()).toBe(false); }); } @@ -247,4 +261,54 @@ describe('Pipeline Wizard - wrapper.vue', () => { expect(wrapper.getComponent(YamlEditor).props('highlight')).toBe(null); }); }); + + describe('integration test', () => { + beforeAll(async () => { + createComponent({}, mountExtended); + }); + + it('updates the editor content after input on step 1', async () => { + findFirstInputFieldForTarget('$FOO').setValue('fooVal'); + await nextTick(); + + expect(getEditorContent()).toBe(compiledScenario1); + }); + + it('updates the editor content after input on step 2', async () => { + findFirstVisibleStep().vm.$emit('next'); + await nextTick(); + + findFirstInputFieldForTarget('$BAR').setValue('barVal'); + await nextTick(); + + expect(getEditorContent()).toBe(compiledScenario2); + }); + + describe('navigating back', () => { + let inputField; + + beforeAll(async () => { + findFirstVisibleStep().vm.$emit('back'); + await nextTick(); + + inputField = findFirstInputFieldForTarget('$FOO'); + }); + + afterAll(() => { + wrapper.destroy(); + inputField = undefined; + }); + + it('still shows the input values from the former visit', () => { + expect(inputField.element.value).toBe('fooVal'); + }); + + it('updates the editor content without modifying input that came from a later step', async () => { + inputField.setValue('newFooVal'); + await nextTick(); + + expect(getEditorContent()).toBe(compiledScenario3); + }); + }); + }); }); diff --git a/spec/frontend/pipeline_wizard/mock/yaml.js b/spec/frontend/pipeline_wizard/mock/yaml.js index 5eaeaa32a8c..e7087b59ce7 100644 --- a/spec/frontend/pipeline_wizard/mock/yaml.js +++ b/spec/frontend/pipeline_wizard/mock/yaml.js @@ -59,6 +59,17 @@ export const steps = ` bar: $BAR `; +export const compiledScenario1 = `foo: fooVal +`; + +export const compiledScenario2 = `foo: fooVal +bar: barVal +`; + +export const compiledScenario3 = `foo: newFooVal +bar: barVal +`; + export const fullTemplate = ` title: some title description: some description diff --git a/spec/frontend/pipelines/components/pipeline_tabs_spec.js b/spec/frontend/pipelines/components/pipeline_tabs_spec.js new file mode 100644 index 00000000000..e18c3edbad9 --- /dev/null +++ b/spec/frontend/pipelines/components/pipeline_tabs_spec.js @@ -0,0 +1,61 @@ +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import PipelineTabs from '~/pipelines/components/pipeline_tabs.vue'; +import PipelineGraphWrapper from '~/pipelines/components/graph/graph_component_wrapper.vue'; +import Dag from '~/pipelines/components/dag/dag.vue'; +import JobsApp from '~/pipelines/components/jobs/jobs_app.vue'; +import TestReports from '~/pipelines/components/test_reports/test_reports.vue'; + +describe('The Pipeline Tabs', () => { + let wrapper; + + const findDagTab = () => wrapper.findByTestId('dag-tab'); + const findFailedJobsTab = () => wrapper.findByTestId('failed-jobs-tab'); + const findJobsTab = () => wrapper.findByTestId('jobs-tab'); + const findPipelineTab = () => wrapper.findByTestId('pipeline-tab'); + const findTestsTab = () => wrapper.findByTestId('tests-tab'); + + const findDagApp = () => wrapper.findComponent(Dag); + const findFailedJobsApp = () => wrapper.findComponent(JobsApp); + const findJobsApp = () => wrapper.findComponent(JobsApp); + const findPipelineApp = () => wrapper.findComponent(PipelineGraphWrapper); + const findTestsApp = () => wrapper.findComponent(TestReports); + + const createComponent = (propsData = {}) => { + wrapper = extendedWrapper( + shallowMount(PipelineTabs, { + propsData, + stubs: { + Dag: { template: '<div id="dag"/>' }, + JobsApp: { template: '<div class="jobs" />' }, + PipelineGraph: { template: '<div id="graph" />' }, + TestReports: { template: '<div id="tests" />' }, + }, + }), + ); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + // The failed jobs MUST be removed from here and tested individually once + // the logic for the tab is implemented. + describe('Tabs', () => { + it.each` + tabName | tabComponent | appComponent + ${'Pipeline'} | ${findPipelineTab} | ${findPipelineApp} + ${'Dag'} | ${findDagTab} | ${findDagApp} + ${'Jobs'} | ${findJobsTab} | ${findJobsApp} + ${'Failed Jobs'} | ${findFailedJobsTab} | ${findFailedJobsApp} + ${'Tests'} | ${findTestsTab} | ${findTestsApp} + `('shows $tabName tab and its associated component', ({ appComponent, tabComponent }) => { + expect(tabComponent().exists()).toBe(true); + expect(appComponent().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js index 0822b293f75..6c743f92116 100644 --- a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js +++ b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js @@ -173,7 +173,7 @@ describe('Pipelines filtered search', () => { { type: 'filtered-search-term', value: { data: '' } }, ]; - expect(findFilteredSearch().props('value')).toEqual(expectedValueProp); + expect(findFilteredSearch().props('value')).toMatchObject(expectedValueProp); expect(findFilteredSearch().props('value')).toHaveLength(expectedValueProp.length); }); }); diff --git a/spec/frontend/pipelines/empty_state/ci_templates_spec.js b/spec/frontend/pipelines/empty_state/ci_templates_spec.js new file mode 100644 index 00000000000..606fdc9cac1 --- /dev/null +++ b/spec/frontend/pipelines/empty_state/ci_templates_spec.js @@ -0,0 +1,85 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import CiTemplates from '~/pipelines/components/pipelines_list/empty_state/ci_templates.vue'; + +const pipelineEditorPath = '/-/ci/editor'; +const suggestedCiTemplates = [ + { name: 'Android', logo: '/assets/illustrations/logos/android.svg' }, + { name: 'Bash', logo: '/assets/illustrations/logos/bash.svg' }, + { name: 'C++', logo: '/assets/illustrations/logos/c_plus_plus.svg' }, +]; + +describe('CI Templates', () => { + let wrapper; + let trackingSpy; + + const createWrapper = () => { + return shallowMountExtended(CiTemplates, { + provide: { + pipelineEditorPath, + suggestedCiTemplates, + }, + }); + }; + + const findTemplateDescription = () => wrapper.findByTestId('template-description'); + const findTemplateLink = () => wrapper.findByTestId('template-link'); + const findTemplateName = () => wrapper.findByTestId('template-name'); + const findTemplateLogo = () => wrapper.findByTestId('template-logo'); + + beforeEach(() => { + wrapper = createWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('renders template list', () => { + it('renders all suggested templates', () => { + const content = wrapper.text(); + + expect(content).toContain('Android', 'Bash', 'C++'); + }); + + it('has the correct template name', () => { + expect(findTemplateName().text()).toBe('Android'); + }); + + it('links to the correct template', () => { + expect(findTemplateLink().attributes('href')).toBe( + pipelineEditorPath.concat('?template=Android'), + ); + }); + + it('has the description of the template', () => { + expect(findTemplateDescription().text()).toBe( + 'CI/CD template to test and deploy your Android project.', + ); + }); + + it('has the right logo of the template', () => { + expect(findTemplateLogo().attributes('src')).toBe('/assets/illustrations/logos/android.svg'); + }); + }); + + describe('tracking', () => { + beforeEach(() => { + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('sends an event when template is clicked', () => { + findTemplateLink().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { + label: 'Android', + }); + }); + }); +}); diff --git a/spec/frontend/pipelines/pipelines_ci_templates_spec.js b/spec/frontend/pipelines/empty_state/pipelines_ci_templates_spec.js index 7064f7448ec..14860f20317 100644 --- a/spec/frontend/pipelines/pipelines_ci_templates_spec.js +++ b/spec/frontend/pipelines/empty_state/pipelines_ci_templates_spec.js @@ -1,12 +1,12 @@ import '~/commons'; import { GlButton, GlSprintf } from '@gitlab/ui'; -import { sprintf } from '~/locale'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { mockTracking } from 'helpers/tracking_helper'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { stubExperiments } from 'helpers/experimentation_helper'; import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; import ExperimentTracking from '~/experimentation/experiment_tracking'; -import PipelinesCiTemplate from '~/pipelines/components/pipelines_list/pipelines_ci_templates.vue'; +import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue'; +import CiTemplates from '~/pipelines/components/pipelines_list/empty_state/ci_templates.vue'; import { RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME, RUNNERS_SETTINGS_LINK_CLICKED_EVENT, @@ -16,11 +16,6 @@ import { } from '~/pipeline_editor/constants'; const pipelineEditorPath = '/-/ci/editor'; -const suggestedCiTemplates = [ - { name: 'Android', logo: '/assets/illustrations/logos/android.svg' }, - { name: 'Bash', logo: '/assets/illustrations/logos/bash.svg' }, - { name: 'C++', logo: '/assets/illustrations/logos/c_plus_plus.svg' }, -]; jest.mock('~/experimentation/experiment_tracking'); @@ -29,21 +24,17 @@ describe('Pipelines CI Templates', () => { let trackingSpy; const createWrapper = (propsData = {}, stubs = {}) => { - return shallowMountExtended(PipelinesCiTemplate, { + return shallowMountExtended(PipelinesCiTemplates, { provide: { pipelineEditorPath, - suggestedCiTemplates, }, propsData, stubs, }); }; - const findTestTemplateLinks = () => wrapper.findAll('[data-testid="test-template-link"]'); - const findTemplateDescriptions = () => wrapper.findAll('[data-testid="template-description"]'); - const findTemplateLinks = () => wrapper.findAll('[data-testid="template-link"]'); - const findTemplateNames = () => wrapper.findAll('[data-testid="template-name"]'); - const findTemplateLogos = () => wrapper.findAll('[data-testid="template-logo"]'); + const findTestTemplateLink = () => wrapper.findByTestId('test-template-link'); + const findCiTemplates = () => wrapper.findComponent(CiTemplates); const findSettingsLink = () => wrapper.findByTestId('settings-link'); const findDocumentationLink = () => wrapper.findByTestId('documentation-link'); const findSettingsButton = () => wrapper.findByTestId('settings-button'); @@ -59,63 +50,24 @@ describe('Pipelines CI Templates', () => { }); it('links to the getting started template', () => { - expect(findTestTemplateLinks().at(0).attributes('href')).toBe( + expect(findTestTemplateLink().attributes('href')).toBe( pipelineEditorPath.concat('?template=Getting-Started'), ); }); }); - describe('renders template list', () => { - beforeEach(() => { - wrapper = createWrapper(); - }); - - it('renders all suggested templates', () => { - const content = wrapper.text(); - - expect(content).toContain('Android', 'Bash', 'C++'); - }); - - it('has the correct template name', () => { - expect(findTemplateNames().at(0).text()).toBe('Android'); - }); - - it('links to the correct template', () => { - expect(findTemplateLinks().at(0).attributes('href')).toBe( - pipelineEditorPath.concat('?template=Android'), - ); - }); - - it('has the description of the template', () => { - expect(findTemplateDescriptions().at(0).text()).toBe( - sprintf(I18N.templates.description, { name: 'Android' }), - ); - }); - - it('has the right logo of the template', () => { - expect(findTemplateLogos().at(0).attributes('src')).toBe( - '/assets/illustrations/logos/android.svg', - ); - }); - }); - describe('tracking', () => { beforeEach(() => { wrapper = createWrapper(); trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }); - it('sends an event when template is clicked', () => { - findTemplateLinks().at(0).vm.$emit('click'); - - expect(trackingSpy).toHaveBeenCalledTimes(1); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { - label: 'Android', - }); + afterEach(() => { + unmockTracking(); }); it('sends an event when Getting-Started template is clicked', () => { - findTestTemplateLinks().at(0).vm.$emit('click'); + findTestTemplateLink().vm.$emit('click'); expect(trackingSpy).toHaveBeenCalledTimes(1); expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { @@ -198,8 +150,8 @@ describe('Pipelines CI Templates', () => { }); it(`renders the templates: ${templatesRendered}`, () => { - expect(findTestTemplateLinks().exists()).toBe(templatesRendered); - expect(findTemplateLinks().exists()).toBe(templatesRendered); + expect(findTestTemplateLink().exists()).toBe(templatesRendered); + expect(findCiTemplates().exists()).toBe(templatesRendered); }); }, ); diff --git a/spec/frontend/pipelines/empty_state_spec.js b/spec/frontend/pipelines/empty_state_spec.js index 31b74a06efd..46dad4a035c 100644 --- a/spec/frontend/pipelines/empty_state_spec.js +++ b/spec/frontend/pipelines/empty_state_spec.js @@ -1,7 +1,7 @@ import '~/commons'; import { mount } from '@vue/test-utils'; import EmptyState from '~/pipelines/components/pipelines_list/empty_state.vue'; -import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/pipelines_ci_templates.vue'; +import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue'; describe('Pipelines Empty State', () => { let wrapper; diff --git a/spec/frontend/pipelines/graph/action_component_spec.js b/spec/frontend/pipelines/graph/action_component_spec.js index fab6e6887b7..6e5aa572ec0 100644 --- a/spec/frontend/pipelines/graph/action_component_spec.js +++ b/spec/frontend/pipelines/graph/action_component_spec.js @@ -48,17 +48,14 @@ describe('pipeline graph action component', () => { }); describe('on click', () => { - it('emits `pipelineActionRequestComplete` after a successful request', (done) => { + it('emits `pipelineActionRequestComplete` after a successful request', async () => { jest.spyOn(wrapper.vm, '$emit'); findButton().trigger('click'); - waitForPromises() - .then(() => { - expect(wrapper.vm.$emit).toHaveBeenCalledWith('pipelineActionRequestComplete'); - done(); - }) - .catch(done.fail); + await waitForPromises(); + + expect(wrapper.vm.$emit).toHaveBeenCalledWith('pipelineActionRequestComplete'); }); it('renders a loading icon while waiting for request', async () => { diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js index 8bc6c086b9d..cb7073fb5f5 100644 --- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js @@ -30,6 +30,7 @@ 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 * 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'; @@ -55,6 +56,7 @@ describe('Pipeline graph wrapper', () => { wrapper.find(StageColumnComponent).findAll('[data-testid="stage-column-group"]'); const getViewSelector = () => wrapper.find(GraphViewSelector); const getViewSelectorTrip = () => getViewSelector().findComponent(GlAlert); + const getLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); const createComponent = ({ apolloProvider, @@ -376,6 +378,10 @@ describe('Pipeline graph wrapper', () => { localStorage.clear(); }); + it('sets the asString prop on the LocalStorageSync component', () => { + expect(getLocalStorageSync().props('asString')).toBe(true); + }); + it('reads the view type from localStorage when available', () => { const viewSelectorNeedsSegment = wrapper .find(GlButtonGroup) diff --git a/spec/frontend/pipelines/pipeline_triggerer_spec.js b/spec/frontend/pipelines/pipeline_triggerer_spec.js index 701b1691c7b..58bfb68e85c 100644 --- a/spec/frontend/pipelines/pipeline_triggerer_spec.js +++ b/spec/frontend/pipelines/pipeline_triggerer_spec.js @@ -1,17 +1,11 @@ -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import { GlAvatar, GlAvatarLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import pipelineTriggerer from '~/pipelines/components/pipelines_list/pipeline_triggerer.vue'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; describe('Pipelines Triggerer', () => { let wrapper; - const expectComponentWithProps = (Component, props = {}) => { - const componentWrapper = wrapper.find(Component); - expect(componentWrapper.isVisible()).toBe(true); - expect(componentWrapper.props()).toEqual(expect.objectContaining(props)); - }; - const mockData = { pipeline: { user: { @@ -22,40 +16,65 @@ describe('Pipelines Triggerer', () => { }, }; - const createComponent = () => { - wrapper = shallowMount(pipelineTriggerer, { - propsData: mockData, + const createComponent = (props) => { + wrapper = shallowMountExtended(pipelineTriggerer, { + propsData: { + ...props, + }, + directives: { + GlTooltip: createMockDirective(), + }, }); }; - beforeEach(() => { - createComponent(); - }); - afterEach(() => { wrapper.destroy(); }); - it('should render pipeline triggerer table cell', () => { - expect(wrapper.find('[data-testid="pipeline-triggerer"]').exists()).toBe(true); - }); + const findAvatarLink = () => wrapper.findComponent(GlAvatarLink); + const findAvatar = () => wrapper.findComponent(GlAvatar); + const findTriggerer = () => wrapper.findByText('API'); + + describe('when user was a triggerer', () => { + beforeEach(() => { + createComponent(mockData); + }); + + it('should render pipeline triggerer table cell', () => { + expect(wrapper.find('[data-testid="pipeline-triggerer"]').exists()).toBe(true); + }); + + it('should render only user avatar', () => { + expect(findAvatarLink().exists()).toBe(true); + expect(findTriggerer().exists()).toBe(false); + }); + + it('should set correct props on avatar link component', () => { + expect(findAvatarLink().attributes()).toMatchObject({ + title: mockData.pipeline.user.name, + href: mockData.pipeline.user.path, + }); + }); - it('should pass triggerer information when triggerer is provided', () => { - expectComponentWithProps(UserAvatarLink, { - linkHref: mockData.pipeline.user.path, - tooltipText: mockData.pipeline.user.name, - imgSrc: mockData.pipeline.user.avatar_url, + it('should add tooltip to avatar link', () => { + const tooltip = getBinding(findAvatarLink().element, 'gl-tooltip'); + + expect(tooltip).toBeDefined(); + }); + + it('should set correct props on avatar component', () => { + expect(findAvatar().attributes().src).toBe(mockData.pipeline.user.avatar_url); }); }); - it('should render "API" when no triggerer is provided', async () => { - wrapper.setProps({ - pipeline: { - user: null, - }, + describe('when API was a triggerer', () => { + beforeEach(() => { + createComponent({ pipeline: {} }); }); - await nextTick(); - expect(wrapper.find('.js-pipeline-url-api').text()).toEqual('API'); + it('should render label only', () => { + expect(findAvatarLink().exists()).toBe(false); + expect(findTriggerer().exists()).toBe(true); + }); }); }); diff --git a/spec/frontend/pipelines/pipeline_url_spec.js b/spec/frontend/pipelines/pipeline_url_spec.js index 2a0aeed917c..c6104a13216 100644 --- a/spec/frontend/pipelines/pipeline_url_spec.js +++ b/spec/frontend/pipelines/pipeline_url_spec.js @@ -1,5 +1,6 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import PipelineUrlComponent from '~/pipelines/components/pipelines_list/pipeline_url.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import { mockPipeline, mockPipelineBranch, mockPipelineTag } from './mock_data'; const projectPath = 'test/test'; @@ -57,6 +58,30 @@ describe('Pipeline Url Component', () => { expect(findCommitShortSha().exists()).toBe(true); }); + describe('commit user avatar', () => { + it('renders when commit author exists', () => { + const pipelineBranch = mockPipelineBranch(); + const { avatar_url, name, path } = pipelineBranch.pipeline.commit.author; + createComponent(pipelineBranch); + + const component = wrapper.findComponent(UserAvatarLink); + expect(component.exists()).toBe(true); + expect(component.props()).toMatchObject({ + imgSize: 16, + imgSrc: avatar_url, + imgAlt: name, + linkHref: path, + tooltipText: name, + }); + }); + + it('does not render when commit author does not exist', () => { + createComponent(); + + expect(wrapper.findComponent(UserAvatarLink).exists()).toBe(false); + }); + }); + it('should render commit icon tooltip', () => { createComponent({}, true); diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index 20ed12cd1f5..d2b30c93746 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -14,7 +14,7 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import NavigationControls from '~/pipelines/components/pipelines_list/nav_controls.vue'; import PipelinesComponent from '~/pipelines/components/pipelines_list/pipelines.vue'; -import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/pipelines_ci_templates.vue'; +import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue'; import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue'; import { RAW_TEXT_WARNING } from '~/pipelines/constants'; import Store from '~/pipelines/stores/pipelines_store'; diff --git a/spec/frontend/pipelines/test_reports/stores/actions_spec.js b/spec/frontend/pipelines/test_reports/stores/actions_spec.js index 84a9f4776b9..d5acb115bc1 100644 --- a/spec/frontend/pipelines/test_reports/stores/actions_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/actions_spec.js @@ -38,29 +38,25 @@ describe('Actions TestReports Store', () => { mock.onGet(summaryEndpoint).replyOnce(200, summary, {}); }); - it('sets testReports and shows tests', (done) => { - testAction( + it('sets testReports and shows tests', () => { + return testAction( actions.fetchSummary, null, state, [{ type: types.SET_SUMMARY, payload: summary }], [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], - done, ); }); - it('should create flash on API error', (done) => { - testAction( + it('should create flash on API error', async () => { + await testAction( actions.fetchSummary, null, { summaryEndpoint: null }, [], [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], - () => { - expect(createFlash).toHaveBeenCalled(); - done(); - }, ); + expect(createFlash).toHaveBeenCalled(); }); }); @@ -73,87 +69,80 @@ describe('Actions TestReports Store', () => { .replyOnce(200, testReports.test_suites[0], {}); }); - it('sets test suite and shows tests', (done) => { + it('sets test suite and shows tests', () => { const suite = testReports.test_suites[0]; const index = 0; - testAction( + return testAction( actions.fetchTestSuite, index, { ...state, testReports }, [{ type: types.SET_SUITE, payload: { suite, index } }], [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], - done, ); }); - it('should create flash on API error', (done) => { + it('should create flash on API error', async () => { const index = 0; - testAction( + await testAction( actions.fetchTestSuite, index, { ...state, testReports, suiteEndpoint: null }, [], [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], - () => { - expect(createFlash).toHaveBeenCalled(); - done(); - }, ); + expect(createFlash).toHaveBeenCalled(); }); describe('when we already have the suite data', () => { - it('should not fetch suite', (done) => { + it('should not fetch suite', () => { const index = 0; testReports.test_suites[0].hasFullSuite = true; - testAction(actions.fetchTestSuite, index, { ...state, testReports }, [], [], done); + return testAction(actions.fetchTestSuite, index, { ...state, testReports }, [], []); }); }); }); describe('set selected suite index', () => { - it('sets selectedSuiteIndex', (done) => { + it('sets selectedSuiteIndex', () => { const selectedSuiteIndex = 0; - testAction( + return testAction( actions.setSelectedSuiteIndex, selectedSuiteIndex, { ...state, hasFullReport: true }, [{ type: types.SET_SELECTED_SUITE_INDEX, payload: selectedSuiteIndex }], [], - done, ); }); }); describe('remove selected suite index', () => { - it('sets selectedSuiteIndex to null', (done) => { - testAction( + it('sets selectedSuiteIndex to null', () => { + return testAction( actions.removeSelectedSuiteIndex, {}, state, [{ type: types.SET_SELECTED_SUITE_INDEX, payload: null }], [], - done, ); }); }); describe('toggles loading', () => { - it('sets isLoading to true', (done) => { - testAction(actions.toggleLoading, {}, state, [{ type: types.TOGGLE_LOADING }], [], done); + it('sets isLoading to true', () => { + return testAction(actions.toggleLoading, {}, state, [{ type: types.TOGGLE_LOADING }], []); }); - it('toggles isLoading to false', (done) => { - testAction( + it('toggles isLoading to false', () => { + return testAction( actions.toggleLoading, {}, { ...state, isLoading: true }, [{ type: types.TOGGLE_LOADING }], [], - done, ); }); }); diff --git a/spec/frontend/profile/add_ssh_key_validation_spec.js b/spec/frontend/profile/add_ssh_key_validation_spec.js index a6bcca0ccb3..730a94592a7 100644 --- a/spec/frontend/profile/add_ssh_key_validation_spec.js +++ b/spec/frontend/profile/add_ssh_key_validation_spec.js @@ -1,4 +1,4 @@ -import AddSshKeyValidation from '../../../app/assets/javascripts/profile/add_ssh_key_validation'; +import AddSshKeyValidation from '~/profile/add_ssh_key_validation'; describe('AddSshKeyValidation', () => { describe('submit', () => { diff --git a/spec/frontend/profile/preferences/components/__snapshots__/diffs_colors_preview_spec.js.snap b/spec/frontend/profile/preferences/components/__snapshots__/diffs_colors_preview_spec.js.snap new file mode 100644 index 00000000000..3025a2f87ae --- /dev/null +++ b/spec/frontend/profile/preferences/components/__snapshots__/diffs_colors_preview_spec.js.snap @@ -0,0 +1,915 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DiffsColorsPreview component renders diff colors preview 1`] = ` +<div + class="form-group" +> + <label> + Preview + </label> + + <table + class="code" + > + <tbody> + <tr + class="line_holder parallel" + > + <td + class="old_line diff-line-num old" + > + <a + data-linenumber="1" + /> + </td> + + <td + class="line_content parallel left-side old" + > + <span> + <span + class="c1" + > + # + <span + class="idiff deletion" + > + Removed + </span> + content + </span> + </span> + </td> + + <td + class="new_line diff-line-num new" + > + <a + data-linenumber="1" + /> + </td> + + <td + class="line_content parallel right-side new" + > + <span> + <span + class="c1" + > + # + <span + class="idiff addition" + > + Added + </span> + content + </span> + </span> + </td> + </tr> + + <tr + class="line_holder parallel" + > + <td + class="old_line diff-line-num old" + > + <a + data-linenumber="2" + /> + </td> + + <td + class="line_content parallel left-side old" + > + <span> + <span + class="n" + > + v + </span> + + <span + class="o" + > + = + </span> + + <span + class="mi" + > + 1 + </span> + </span> + </td> + + <td + class="new_line diff-line-num new" + > + <a + data-linenumber="2" + /> + </td> + + <td + class="line_content parallel right-side new" + > + <span> + <span + class="n" + > + v + </span> + + <span + class="o" + > + = + </span> + + <span + class="mi" + > + 1 + </span> + </span> + </td> + </tr> + + <tr + class="line_holder parallel" + > + <td + class="old_line diff-line-num old" + > + <a + data-linenumber="3" + /> + </td> + + <td + class="line_content parallel left-side old" + > + <span> + <span + class="n" + > + s + </span> + + <span + class="o" + > + = + </span> + + <span + class="s" + > + "string" + </span> + </span> + </td> + + <td + class="new_line diff-line-num new" + > + <a + data-linenumber="3" + /> + </td> + + <td + class="line_content parallel right-side new" + > + <span> + <span + class="n" + > + s + </span> + + <span + class="o" + > + = + </span> + + <span + class="s" + > + "string" + </span> + </span> + </td> + </tr> + + <tr + class="line_holder parallel" + > + <td + class="old_line diff-line-num old" + > + <a + data-linenumber="4" + /> + </td> + + <td + class="line_content parallel left-side old" + > + <span /> + </td> + + <td + class="new_line diff-line-num new" + > + <a + data-linenumber="4" + /> + </td> + + <td + class="line_content parallel right-side new" + > + <span /> + </td> + </tr> + + <tr + class="line_holder parallel" + > + <td + class="old_line diff-line-num old" + > + <a + data-linenumber="5" + /> + </td> + + <td + class="line_content parallel left-side old" + > + <span> + <span + class="k" + > + for + </span> + + <span + class="n" + > + i + </span> + + <span + class="ow" + > + in + </span> + + <span + class="nb" + > + range + </span> + <span + class="p" + > + ( + </span> + <span + class="o" + > + - + </span> + <span + class="mi" + > + 10 + </span> + <span + class="p" + > + , + </span> + + <span + class="mi" + > + 10 + </span> + <span + class="p" + > + ): + </span> + </span> + </td> + + <td + class="new_line diff-line-num new" + > + <a + data-linenumber="5" + /> + </td> + + <td + class="line_content parallel right-side new" + > + <span> + <span + class="k" + > + for + </span> + + <span + class="n" + > + i + </span> + + <span + class="ow" + > + in + </span> + + <span + class="nb" + > + range + </span> + <span + class="p" + > + ( + </span> + <span + class="o" + > + - + </span> + <span + class="mi" + > + 10 + </span> + <span + class="p" + > + , + </span> + + <span + class="mi" + > + 10 + </span> + <span + class="p" + > + ): + </span> + </span> + </td> + </tr> + + <tr + class="line_holder parallel" + > + <td + class="old_line diff-line-num old" + > + <a + data-linenumber="6" + /> + </td> + + <td + class="line_content parallel left-side old" + > + <span> + <span> + + </span> + + <span + class="k" + > + print + </span> + <span + class="p" + > + ( + </span> + <span + class="n" + > + i + </span> + + <span + class="o" + > + + + </span> + + <span + class="mi" + > + 1 + </span> + <span + class="p" + > + ) + </span> + </span> + </td> + + <td + class="new_line diff-line-num new" + > + <a + data-linenumber="6" + /> + </td> + + <td + class="line_content parallel right-side new" + > + <span> + <span> + + </span> + + <span + class="k" + > + print + </span> + <span + class="p" + > + ( + </span> + <span + class="n" + > + i + </span> + + <span + class="o" + > + + + </span> + + <span + class="mi" + > + 1 + </span> + <span + class="p" + > + ) + </span> + </span> + </td> + </tr> + + <tr + class="line_holder parallel" + > + <td + class="old_line diff-line-num old" + > + <a + data-linenumber="7" + /> + </td> + + <td + class="line_content parallel left-side old" + > + <span /> + </td> + + <td + class="new_line diff-line-num new" + > + <a + data-linenumber="7" + /> + </td> + + <td + class="line_content parallel right-side new" + > + <span /> + </td> + </tr> + + <tr + class="line_holder parallel" + > + <td + class="old_line diff-line-num old" + > + <a + data-linenumber="8" + /> + </td> + + <td + class="line_content parallel left-side old" + > + <span> + <span + class="k" + > + class + </span> + + <span + class="nc" + > + LinkedList + </span> + <span + class="p" + > + ( + </span> + <span + class="nb" + > + object + </span> + <span + class="p" + > + ): + </span> + </span> + </td> + + <td + class="new_line diff-line-num new" + > + <a + data-linenumber="8" + /> + </td> + + <td + class="line_content parallel right-side new" + > + <span> + <span + class="k" + > + class + </span> + + <span + class="nc" + > + LinkedList + </span> + <span + class="p" + > + ( + </span> + <span + class="nb" + > + object + </span> + <span + class="p" + > + ): + </span> + </span> + </td> + </tr> + + <tr + class="line_holder parallel" + > + <td + class="old_line diff-line-num old" + > + <a + data-linenumber="9" + /> + </td> + + <td + class="line_content parallel left-side old" + > + <span> + <span> + + </span> + + <span + class="k" + > + def + </span> + + <span + class="nf" + > + __init__ + </span> + <span + class="p" + > + ( + </span> + <span + class="bp" + > + self + </span> + <span + class="p" + > + , + </span> + + <span + class="n" + > + x + </span> + <span + class="p" + > + ): + </span> + </span> + </td> + + <td + class="new_line diff-line-num new" + > + <a + data-linenumber="9" + /> + </td> + + <td + class="line_content parallel right-side new" + > + <span> + <span> + + </span> + + <span + class="k" + > + def + </span> + + <span + class="nf" + > + __init__ + </span> + <span + class="p" + > + ( + </span> + <span + class="bp" + > + self + </span> + <span + class="p" + > + , + </span> + + <span + class="n" + > + x + </span> + <span + class="p" + > + ): + </span> + </span> + </td> + </tr> + + <tr + class="line_holder parallel" + > + <td + class="old_line diff-line-num old" + > + <a + data-linenumber="10" + /> + </td> + + <td + class="line_content parallel left-side old" + > + <span> + <span> + + </span> + + <span + class="bp" + > + self + </span> + <span + class="p" + > + . + </span> + <span + class="n" + > + val + </span> + + <span + class="o" + > + = + </span> + + <span + class="n" + > + x + </span> + </span> + </td> + + <td + class="new_line diff-line-num new" + > + <a + data-linenumber="10" + /> + </td> + + <td + class="line_content parallel right-side new" + > + <span> + <span> + + </span> + + <span + class="bp" + > + self + </span> + <span + class="p" + > + . + </span> + <span + class="n" + > + val + </span> + + <span + class="o" + > + = + </span> + + <span + class="n" + > + x + </span> + </span> + </td> + </tr> + + <tr + class="line_holder parallel" + > + <td + class="old_line diff-line-num old" + > + <a + data-linenumber="11" + /> + </td> + + <td + class="line_content parallel left-side old" + > + <span> + <span> + + </span> + + <span + class="bp" + > + self + </span> + <span + class="p" + > + . + </span> + <span + class="nb" + > + next + </span> + + <span + class="o" + > + = + </span> + + <span + class="bp" + > + None + </span> + </span> + </td> + + <td + class="new_line diff-line-num new" + > + <a + data-linenumber="11" + /> + </td> + + <td + class="line_content parallel right-side new" + > + <span> + <span> + + </span> + + <span + class="bp" + > + self + </span> + <span + class="p" + > + . + </span> + <span + class="nb" + > + next + </span> + + <span + class="o" + > + = + </span> + + <span + class="bp" + > + None + </span> + </span> + </td> + </tr> + </tbody> + </table> +</div> +`; diff --git a/spec/frontend/profile/preferences/components/diffs_colors_preview_spec.js b/spec/frontend/profile/preferences/components/diffs_colors_preview_spec.js new file mode 100644 index 00000000000..e60602ab336 --- /dev/null +++ b/spec/frontend/profile/preferences/components/diffs_colors_preview_spec.js @@ -0,0 +1,23 @@ +import { shallowMount } from '@vue/test-utils'; +import DiffsColorsPreview from '~/profile/preferences/components/diffs_colors_preview.vue'; + +describe('DiffsColorsPreview component', () => { + let wrapper; + + function createComponent() { + wrapper = shallowMount(DiffsColorsPreview); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders diff colors preview', () => { + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/profile/preferences/components/diffs_colors_spec.js b/spec/frontend/profile/preferences/components/diffs_colors_spec.js new file mode 100644 index 00000000000..02f501a0b06 --- /dev/null +++ b/spec/frontend/profile/preferences/components/diffs_colors_spec.js @@ -0,0 +1,153 @@ +import { shallowMount } from '@vue/test-utils'; +import { s__ } from '~/locale'; +import ColorPicker from '~/vue_shared/components/color_picker/color_picker.vue'; +import DiffsColors from '~/profile/preferences/components/diffs_colors.vue'; +import DiffsColorsPreview from '~/profile/preferences/components/diffs_colors_preview.vue'; +import * as CssUtils from '~/lib/utils/css_utils'; + +describe('DiffsColors component', () => { + let wrapper; + + const defaultInjectedProps = { + addition: '#00ff00', + deletion: '#ff0000', + }; + + const initialSuggestedColors = { + '#d99530': s__('SuggestedColors|Orange'), + '#1f75cb': s__('SuggestedColors|Blue'), + }; + + const findColorPickers = () => wrapper.findAllComponents(ColorPicker); + + function createComponent(provide = {}) { + wrapper = shallowMount(DiffsColors, { + provide: { + ...defaultInjectedProps, + ...provide, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('mounts', () => { + createComponent(); + + expect(wrapper.exists()).toBe(true); + }); + + describe('preview', () => { + it('should render preview', () => { + createComponent(); + + expect(wrapper.findComponent(DiffsColorsPreview).exists()).toBe(true); + }); + + it('should set preview classes', () => { + createComponent(); + + expect(wrapper.attributes('class')).toBe( + 'diff-custom-addition-color diff-custom-deletion-color', + ); + }); + + it.each([ + [{ addition: null }, 'diff-custom-deletion-color'], + [{ deletion: null }, 'diff-custom-addition-color'], + ])('should not set preview class if color not set', (provide, expectedClass) => { + createComponent(provide); + + expect(wrapper.attributes('class')).toBe(expectedClass); + }); + + it.each([ + [{}, '--diff-deletion-color: rgba(255,0,0,0.2); --diff-addition-color: rgba(0,255,0,0.2);'], + [{ addition: null }, '--diff-deletion-color: rgba(255,0,0,0.2);'], + [{ deletion: null }, '--diff-addition-color: rgba(0,255,0,0.2);'], + ])('should set correct CSS variables', (provide, expectedStyle) => { + createComponent(provide); + + expect(wrapper.attributes('style')).toBe(expectedStyle); + }); + }); + + describe('color pickers', () => { + it('should render both color pickers', () => { + createComponent(); + + const colorPickers = findColorPickers(); + + expect(colorPickers.length).toBe(2); + expect(colorPickers.at(0).props()).toMatchObject({ + label: s__('Preferences|Color for removed lines'), + value: '#ff0000', + state: true, + }); + expect(colorPickers.at(1).props()).toMatchObject({ + label: s__('Preferences|Color for added lines'), + value: '#00ff00', + state: true, + }); + }); + + describe('suggested colors', () => { + const suggestedColors = () => findColorPickers().at(0).props('suggestedColors'); + + it('contains initial suggested colors', () => { + createComponent(); + + expect(suggestedColors()).toMatchObject(initialSuggestedColors); + }); + + it('contains default diff colors of theme', () => { + jest.spyOn(CssUtils, 'getCssVariable').mockImplementation((variable) => { + if (variable === '--default-diff-color-addition') return '#111111'; + if (variable === '--default-diff-color-deletion') return '#222222'; + return '#000000'; + }); + + createComponent(); + + expect(suggestedColors()).toMatchObject({ + '#111111': s__('SuggestedColors|Default addition color'), + '#222222': s__('SuggestedColors|Default removal color'), + }); + }); + + it('contains current diff colors if set', () => { + createComponent(); + + expect(suggestedColors()).toMatchObject({ + [defaultInjectedProps.addition]: s__('SuggestedColors|Current addition color'), + [defaultInjectedProps.deletion]: s__('SuggestedColors|Current removal color'), + }); + }); + + it.each([ + [ + { addition: null }, + s__('SuggestedColors|Current removal color'), + s__('SuggestedColors|Current addition color'), + ], + [ + { deletion: null }, + s__('SuggestedColors|Current addition color'), + s__('SuggestedColors|Current removal color'), + ], + ])( + 'does not contain current diff color if not set %p', + (provide, expectedToContain, expectNotToContain) => { + createComponent(provide); + + const suggestedColorsLabels = Object.values(suggestedColors()); + expect(suggestedColorsLabels).toContain(expectedToContain); + expect(suggestedColorsLabels).not.toContain(expectNotToContain); + }, + ); + }); + }); +}); diff --git a/spec/frontend/profile/preferences/components/integration_view_spec.js b/spec/frontend/profile/preferences/components/integration_view_spec.js index 6ab0c70298c..92c53b8c91b 100644 --- a/spec/frontend/profile/preferences/components/integration_view_spec.js +++ b/spec/frontend/profile/preferences/components/integration_view_spec.js @@ -1,5 +1,5 @@ -import { GlFormText } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlFormGroup } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import IntegrationView from '~/profile/preferences/components/integration_view.vue'; @@ -21,7 +21,7 @@ describe('IntegrationView component', () => { function createComponent(options = {}) { const { props = {}, provide = {} } = options; - return shallowMount(IntegrationView, { + return mountExtended(IntegrationView, { provide: { userFields, ...provide, @@ -33,28 +33,20 @@ describe('IntegrationView component', () => { }); } - function findCheckbox() { - return wrapper.find('[data-testid="profile-preferences-integration-checkbox"]'); - } - function findFormGroup() { - return wrapper.find('[data-testid="profile-preferences-integration-form-group"]'); - } - function findHiddenField() { - return wrapper.find('[data-testid="profile-preferences-integration-hidden-field"]'); - } - function findFormGroupLabel() { - return wrapper.find('[data-testid="profile-preferences-integration-form-group"] label'); - } + const findCheckbox = () => wrapper.findByLabelText(new RegExp(defaultProps.config.label)); + const findFormGroup = () => wrapper.findComponent(GlFormGroup); + const findHiddenField = () => + wrapper.findByTestId('profile-preferences-integration-hidden-field'); afterEach(() => { wrapper.destroy(); wrapper = null; }); - it('should render the title correctly', () => { + it('should render the form group legend correctly', () => { wrapper = createComponent(); - expect(wrapper.find('label.label-bold').text()).toBe('Foo'); + expect(wrapper.findByText(defaultProps.config.title).exists()).toBe(true); }); it('should render the form correctly', () => { @@ -106,13 +98,6 @@ describe('IntegrationView component', () => { it('should render the help text', () => { wrapper = createComponent(); - expect(wrapper.find(GlFormText).exists()).toBe(true); expect(wrapper.find(IntegrationHelpText).exists()).toBe(true); }); - - it('should render the label correctly', () => { - wrapper = createComponent(); - - expect(findFormGroupLabel().text()).toBe('Enable foo'); - }); }); diff --git a/spec/frontend/projects/commit/store/actions_spec.js b/spec/frontend/projects/commit/store/actions_spec.js index 305257c9ca5..56dffcbd48e 100644 --- a/spec/frontend/projects/commit/store/actions_spec.js +++ b/spec/frontend/projects/commit/store/actions_spec.js @@ -44,12 +44,12 @@ describe('Commit form modal store actions', () => { }); describe('fetchBranches', () => { - it('dispatch correct actions on fetchBranches', (done) => { + it('dispatch correct actions on fetchBranches', () => { jest .spyOn(axios, 'get') .mockImplementation(() => Promise.resolve({ data: { Branches: mockData.mockBranches } })); - testAction( + return testAction( actions.fetchBranches, {}, state, @@ -60,19 +60,15 @@ describe('Commit form modal store actions', () => { }, ], [{ type: 'requestBranches' }], - () => { - done(); - }, ); }); - it('should show flash error and set error in state on fetchBranches failure', (done) => { + it('should show flash error and set error in state on fetchBranches failure', async () => { jest.spyOn(axios, 'get').mockRejectedValue(); - testAction(actions.fetchBranches, {}, state, [], [{ type: 'requestBranches' }], () => { - expect(createFlash).toHaveBeenCalledWith({ message: PROJECT_BRANCHES_ERROR }); - done(); - }); + await testAction(actions.fetchBranches, {}, state, [], [{ type: 'requestBranches' }]); + + expect(createFlash).toHaveBeenCalledWith({ message: PROJECT_BRANCHES_ERROR }); }); }); diff --git a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap index b8f9951bbfc..26a3b27d958 100644 --- a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap +++ b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap @@ -36,7 +36,7 @@ exports[`Project remove modal initialized matches the snapshot 1`] = ` modalclass="" modalid="fakeUniqueId" ok-variant="danger" - size="sm" + size="md" title-class="gl-text-red-500" titletag="h4" > diff --git a/spec/frontend/projects/new/components/deployment_target_select_spec.js b/spec/frontend/projects/new/components/deployment_target_select_spec.js index 8fe4c5f1230..1c443879dc3 100644 --- a/spec/frontend/projects/new/components/deployment_target_select_spec.js +++ b/spec/frontend/projects/new/components/deployment_target_select_spec.js @@ -1,4 +1,5 @@ -import { GlFormGroup, GlFormSelect } from '@gitlab/ui'; +import { GlFormGroup, GlFormSelect, GlFormText, GlLink, GlSprintf } from '@gitlab/ui'; +import { nextTick } from 'vue'; import { shallowMount } from '@vue/test-utils'; import { mockTracking } from 'helpers/tracking_helper'; import DeploymentTargetSelect from '~/projects/new/components/deployment_target_select.vue'; @@ -6,7 +7,9 @@ import { DEPLOYMENT_TARGET_SELECTIONS, DEPLOYMENT_TARGET_LABEL, DEPLOYMENT_TARGET_EVENT, + VISIT_DOCS_EVENT, NEW_PROJECT_FORM, + K8S_OPTION, } from '~/projects/new/constants'; describe('Deployment target select', () => { @@ -15,11 +18,15 @@ describe('Deployment target select', () => { const findFormGroup = () => wrapper.findComponent(GlFormGroup); const findSelect = () => wrapper.findComponent(GlFormSelect); + const findText = () => wrapper.findComponent(GlFormText); + const findLink = () => wrapper.findComponent(GlLink); const createdWrapper = () => { wrapper = shallowMount(DeploymentTargetSelect, { stubs: { GlFormSelect, + GlFormText, + GlSprintf, }, }); }; @@ -79,4 +86,34 @@ describe('Deployment target select', () => { }); } }); + + describe.each` + selectedTarget | isTextShown + ${null} | ${false} + ${DEPLOYMENT_TARGET_SELECTIONS[0]} | ${true} + ${DEPLOYMENT_TARGET_SELECTIONS[1]} | ${false} + `('K8s education text', ({ selectedTarget, isTextShown }) => { + beforeEach(() => { + findSelect().vm.$emit('input', selectedTarget); + }); + + it(`is ${!isTextShown ? 'not ' : ''}shown when selected option is ${selectedTarget}`, () => { + expect(findText().exists()).toBe(isTextShown); + }); + }); + + describe('when user clicks on the docs link', () => { + beforeEach(async () => { + findSelect().vm.$emit('input', K8S_OPTION); + await nextTick(); + + findLink().trigger('click'); + }); + + it('sends the snowplow tracking event', () => { + expect(trackingSpy).toHaveBeenCalledWith('_category_', VISIT_DOCS_EVENT, { + label: DEPLOYMENT_TARGET_LABEL, + }); + }); + }); }); 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 921f5b74278..ba22622e1f7 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 @@ -15,6 +15,7 @@ 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; @@ -61,7 +62,6 @@ describe('NewProjectUrlSelect component', () => { namespaceId: '28', rootUrl: 'https://gitlab.com/', trackLabel: 'blank_project', - userNamespaceFullPath: 'root', userNamespaceId: '1', }; @@ -91,7 +91,10 @@ describe('NewProjectUrlSelect component', () => { const findButtonLabel = () => wrapper.findComponent(GlButton); const findDropdown = () => wrapper.findComponent(GlDropdown); const findInput = () => wrapper.findComponent(GlSearchBoxByType); - const findHiddenInput = () => wrapper.find('[name="project[namespace_id]"]'); + const findHiddenNamespaceInput = () => wrapper.find('[name="project[namespace_id]"]'); + + const findHiddenSelectedNamespaceInput = () => + wrapper.find('[name="project[selected_namespace_id]"]'); const clickDropdownItem = async () => { wrapper.findComponent(GlDropdownItem).vm.$emit('click'); @@ -122,11 +125,20 @@ describe('NewProjectUrlSelect component', () => { }); it('renders a dropdown with the given namespace full path as the text', () => { - expect(findDropdown().props('text')).toBe(defaultProvide.namespaceFullPath); + const dropdownProps = findDropdown().props(); + + expect(dropdownProps.text).toBe(defaultProvide.namespaceFullPath); + expect(dropdownProps.toggleClass).not.toContain('gl-text-gray-500!'); + }); + + it('renders a hidden input with the given namespace id', () => { + expect(findHiddenNamespaceInput().attributes('value')).toBe(defaultProvide.namespaceId); }); - it('renders a dropdown with the given namespace id in the hidden input', () => { - expect(findHiddenInput().attributes('value')).toBe(defaultProvide.namespaceId); + it('renders a hidden input with the selected namespace id', () => { + expect(findHiddenSelectedNamespaceInput().attributes('value')).toBe( + defaultProvide.namespaceId, + ); }); }); @@ -142,11 +154,18 @@ describe('NewProjectUrlSelect component', () => { }); it("renders a dropdown with the user's namespace full path as the text", () => { - expect(findDropdown().props('text')).toBe(defaultProvide.userNamespaceFullPath); + const dropdownProps = findDropdown().props(); + + expect(dropdownProps.text).toBe(s__('ProjectsNew|Pick a group or namespace')); + expect(dropdownProps.toggleClass).toContain('gl-text-gray-500!'); + }); + + it("renders a hidden input with the user's namespace id", () => { + expect(findHiddenNamespaceInput().attributes('value')).toBe(defaultProvide.userNamespaceId); }); - it("renders a dropdown with the user's namespace id in the hidden input", () => { - expect(findHiddenInput().attributes('value')).toBe(defaultProvide.userNamespaceId); + it('renders a hidden input with the selected namespace id', () => { + expect(findHiddenSelectedNamespaceInput().attributes('value')).toBe(undefined); }); }); @@ -270,7 +289,7 @@ describe('NewProjectUrlSelect component', () => { await clickDropdownItem(); - expect(findHiddenInput().attributes('value')).toBe( + expect(findHiddenNamespaceInput().attributes('value')).toBe( getIdFromGraphQLId(data.currentUser.groups.nodes[0].id).toString(), ); }); diff --git a/spec/frontend/releases/components/app_index_apollo_client_spec.js b/spec/frontend/releases/components/app_index_apollo_client_spec.js deleted file mode 100644 index 9881ef9bc9f..00000000000 --- a/spec/frontend/releases/components/app_index_apollo_client_spec.js +++ /dev/null @@ -1,398 +0,0 @@ -import { cloneDeep } from 'lodash'; -import Vue, { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; -import originalAllReleasesQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import allReleasesQuery from 'shared_queries/releases/all_releases.query.graphql'; -import createFlash from '~/flash'; -import { historyPushState } from '~/lib/utils/common_utils'; -import ReleasesIndexApolloClientApp from '~/releases/components/app_index_apollo_client.vue'; -import ReleaseBlock from '~/releases/components/release_block.vue'; -import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue'; -import ReleasesEmptyState from '~/releases/components/releases_empty_state.vue'; -import ReleasesPaginationApolloClient from '~/releases/components/releases_pagination_apollo_client.vue'; -import ReleasesSortApolloClient from '~/releases/components/releases_sort_apollo_client.vue'; -import { PAGE_SIZE, CREATED_ASC, DEFAULT_SORT } from '~/releases/constants'; - -Vue.use(VueApollo); - -jest.mock('~/flash'); - -let mockQueryParams; -jest.mock('~/lib/utils/common_utils', () => ({ - ...jest.requireActual('~/lib/utils/common_utils'), - historyPushState: jest.fn(), -})); - -jest.mock('~/lib/utils/url_utility', () => ({ - ...jest.requireActual('~/lib/utils/url_utility'), - getParameterByName: jest - .fn() - .mockImplementation((parameterName) => mockQueryParams[parameterName]), -})); - -describe('app_index_apollo_client.vue', () => { - const projectPath = 'project/path'; - const newReleasePath = 'path/to/new/release/page'; - const before = 'beforeCursor'; - const after = 'afterCursor'; - - let wrapper; - let allReleases; - let singleRelease; - let noReleases; - let queryMock; - - const createComponent = ({ - singleResponse = Promise.resolve(singleRelease), - fullResponse = Promise.resolve(allReleases), - } = {}) => { - const apolloProvider = createMockApollo([ - [ - allReleasesQuery, - queryMock.mockImplementation((vars) => { - return vars.first === 1 ? singleResponse : fullResponse; - }), - ], - ]); - - wrapper = shallowMountExtended(ReleasesIndexApolloClientApp, { - apolloProvider, - provide: { - newReleasePath, - projectPath, - }, - }); - }; - - beforeEach(() => { - mockQueryParams = {}; - - allReleases = cloneDeep(originalAllReleasesQueryResponse); - - singleRelease = cloneDeep(originalAllReleasesQueryResponse); - singleRelease.data.project.releases.nodes.splice( - 1, - singleRelease.data.project.releases.nodes.length, - ); - - noReleases = cloneDeep(originalAllReleasesQueryResponse); - noReleases.data.project.releases.nodes = []; - - queryMock = jest.fn(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - // Finders - const findLoadingIndicator = () => wrapper.findComponent(ReleaseSkeletonLoader); - const findEmptyState = () => wrapper.findComponent(ReleasesEmptyState); - const findNewReleaseButton = () => - wrapper.findByText(ReleasesIndexApolloClientApp.i18n.newRelease); - const findAllReleaseBlocks = () => wrapper.findAllComponents(ReleaseBlock); - const findPagination = () => wrapper.findComponent(ReleasesPaginationApolloClient); - const findSort = () => wrapper.findComponent(ReleasesSortApolloClient); - - // Tests - describe('component states', () => { - // These need to be defined as functions, since `singleRelease` and - // `allReleases` are generated in a `beforeEach`, and therefore - // aren't available at test definition time. - const getInProgressResponse = () => new Promise(() => {}); - const getErrorResponse = () => Promise.reject(new Error('Oops!')); - const getSingleRequestLoadedResponse = () => Promise.resolve(singleRelease); - const getFullRequestLoadedResponse = () => Promise.resolve(allReleases); - const getLoadedEmptyResponse = () => Promise.resolve(noReleases); - - const toDescription = (bool) => (bool ? 'does' : 'does not'); - - describe.each` - description | singleResponseFn | fullResponseFn | loadingIndicator | emptyState | flashMessage | releaseCount | pagination - ${'both requests loading'} | ${getInProgressResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} - ${'both requests failed'} | ${getErrorResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${0} | ${false} - ${'both requests loaded'} | ${getSingleRequestLoadedResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} - ${'both requests loaded with no results'} | ${getLoadedEmptyResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false} - ${'single request loading, full request loaded'} | ${getInProgressResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} - ${'single request loading, full request failed'} | ${getInProgressResponse} | ${getErrorResponse} | ${true} | ${false} | ${true} | ${0} | ${false} - ${'single request loaded, full request loading'} | ${getSingleRequestLoadedResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${1} | ${false} - ${'single request loaded, full request failed'} | ${getSingleRequestLoadedResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${1} | ${false} - ${'single request failed, full request loading'} | ${getErrorResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} - ${'single request failed, full request loaded'} | ${getErrorResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} - ${'single request loaded with no results, full request loading'} | ${getLoadedEmptyResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} - ${'single request loading, full request loadied with no results'} | ${getInProgressResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false} - `( - '$description', - ({ - singleResponseFn, - fullResponseFn, - loadingIndicator, - emptyState, - flashMessage, - releaseCount, - pagination, - }) => { - beforeEach(() => { - createComponent({ - singleResponse: singleResponseFn(), - fullResponse: fullResponseFn(), - }); - }); - - it(`${toDescription(loadingIndicator)} render a loading indicator`, async () => { - await waitForPromises(); - expect(findLoadingIndicator().exists()).toBe(loadingIndicator); - }); - - it(`${toDescription(emptyState)} render an empty state`, () => { - expect(findEmptyState().exists()).toBe(emptyState); - }); - - it(`${toDescription(flashMessage)} show a flash message`, () => { - if (flashMessage) { - expect(createFlash).toHaveBeenCalledWith({ - message: ReleasesIndexApolloClientApp.i18n.errorMessage, - captureError: true, - error: expect.any(Error), - }); - } else { - expect(createFlash).not.toHaveBeenCalled(); - } - }); - - it(`renders ${releaseCount} release(s)`, () => { - expect(findAllReleaseBlocks()).toHaveLength(releaseCount); - }); - - it(`${toDescription(pagination)} render the pagination controls`, () => { - expect(findPagination().exists()).toBe(pagination); - }); - - it('does render the "New release" button', () => { - expect(findNewReleaseButton().exists()).toBe(true); - }); - - it('does render the sort controls', () => { - expect(findSort().exists()).toBe(true); - }); - }, - ); - }); - - describe('URL parameters', () => { - describe('when the URL contains no query parameters', () => { - beforeEach(() => { - createComponent(); - }); - - it('makes a request with the correct GraphQL query parameters', () => { - expect(queryMock).toHaveBeenCalledTimes(2); - - expect(queryMock).toHaveBeenCalledWith({ - first: 1, - fullPath: projectPath, - sort: DEFAULT_SORT, - }); - - expect(queryMock).toHaveBeenCalledWith({ - first: PAGE_SIZE, - fullPath: projectPath, - sort: DEFAULT_SORT, - }); - }); - }); - - describe('when the URL contains a "before" query parameter', () => { - beforeEach(() => { - mockQueryParams = { before }; - createComponent(); - }); - - it('makes a request with the correct GraphQL query parameters', () => { - expect(queryMock).toHaveBeenCalledTimes(1); - - expect(queryMock).toHaveBeenCalledWith({ - before, - last: PAGE_SIZE, - fullPath: projectPath, - sort: DEFAULT_SORT, - }); - }); - }); - - describe('when the URL contains an "after" query parameter', () => { - beforeEach(() => { - mockQueryParams = { after }; - createComponent(); - }); - - it('makes a request with the correct GraphQL query parameters', () => { - expect(queryMock).toHaveBeenCalledTimes(2); - - expect(queryMock).toHaveBeenCalledWith({ - after, - first: 1, - fullPath: projectPath, - sort: DEFAULT_SORT, - }); - - expect(queryMock).toHaveBeenCalledWith({ - after, - first: PAGE_SIZE, - fullPath: projectPath, - sort: DEFAULT_SORT, - }); - }); - }); - - describe('when the URL contains both "before" and "after" query parameters', () => { - beforeEach(() => { - mockQueryParams = { before, after }; - createComponent(); - }); - - it('ignores the "before" parameter and behaves as if only the "after" parameter was provided', () => { - expect(queryMock).toHaveBeenCalledTimes(2); - - expect(queryMock).toHaveBeenCalledWith({ - after, - first: 1, - fullPath: projectPath, - sort: DEFAULT_SORT, - }); - - expect(queryMock).toHaveBeenCalledWith({ - after, - first: PAGE_SIZE, - fullPath: projectPath, - sort: DEFAULT_SORT, - }); - }); - }); - }); - - describe('New release button', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders the new release button with the correct href', () => { - expect(findNewReleaseButton().attributes().href).toBe(newReleasePath); - }); - }); - - describe('pagination', () => { - beforeEach(() => { - mockQueryParams = { before }; - createComponent(); - }); - - it('requeries the GraphQL endpoint when a pagination button is clicked', async () => { - expect(queryMock.mock.calls).toEqual([[expect.objectContaining({ before })]]); - - mockQueryParams = { after }; - findPagination().vm.$emit('next', after); - - await nextTick(); - - expect(queryMock.mock.calls).toEqual([ - [expect.objectContaining({ before })], - [expect.objectContaining({ after })], - [expect.objectContaining({ after })], - ]); - }); - }); - - describe('sorting', () => { - beforeEach(() => { - createComponent(); - }); - - it(`sorts by ${DEFAULT_SORT} by default`, () => { - expect(queryMock.mock.calls).toEqual([ - [expect.objectContaining({ sort: DEFAULT_SORT })], - [expect.objectContaining({ sort: DEFAULT_SORT })], - ]); - }); - - it('requeries the GraphQL endpoint and updates the URL when the sort is changed', async () => { - findSort().vm.$emit('input', CREATED_ASC); - - await nextTick(); - - expect(queryMock.mock.calls).toEqual([ - [expect.objectContaining({ sort: DEFAULT_SORT })], - [expect.objectContaining({ sort: DEFAULT_SORT })], - [expect.objectContaining({ sort: CREATED_ASC })], - [expect.objectContaining({ sort: CREATED_ASC })], - ]); - - // URL manipulation is tested in more detail in the `describe` block below - expect(historyPushState).toHaveBeenCalled(); - }); - - it('does not requery the GraphQL endpoint or update the URL if the sort is updated to the same value', async () => { - findSort().vm.$emit('input', DEFAULT_SORT); - - await nextTick(); - - expect(queryMock.mock.calls).toEqual([ - [expect.objectContaining({ sort: DEFAULT_SORT })], - [expect.objectContaining({ sort: DEFAULT_SORT })], - ]); - - expect(historyPushState).not.toHaveBeenCalled(); - }); - }); - - describe('sorting + pagination interaction', () => { - const nonPaginationQueryParam = 'nonPaginationQueryParam'; - - beforeEach(() => { - historyPushState.mockImplementation((newUrl) => { - mockQueryParams = Object.fromEntries(new URL(newUrl).searchParams); - }); - }); - - describe.each` - queryParamsBefore | paramName | paramInitialValue - ${{ before, nonPaginationQueryParam }} | ${'before'} | ${before} - ${{ after, nonPaginationQueryParam }} | ${'after'} | ${after} - `( - 'when the URL contains a "$paramName" pagination cursor', - ({ queryParamsBefore, paramName, paramInitialValue }) => { - beforeEach(async () => { - mockQueryParams = queryParamsBefore; - createComponent(); - - findSort().vm.$emit('input', CREATED_ASC); - - await nextTick(); - }); - - it(`resets the page's "${paramName}" pagination cursor when the sort is changed`, () => { - const firstRequestVariables = queryMock.mock.calls[0][0]; - // Might be request #2 or #3, depending on the pagination direction - const mostRecentRequestVariables = - queryMock.mock.calls[queryMock.mock.calls.length - 1][0]; - - expect(firstRequestVariables[paramName]).toBe(paramInitialValue); - expect(mostRecentRequestVariables[paramName]).toBeUndefined(); - }); - - it(`updates the URL to not include the "${paramName}" URL query parameter`, () => { - expect(historyPushState).toHaveBeenCalledTimes(1); - - const updatedUrlQueryParams = Object.fromEntries( - new URL(historyPushState.mock.calls[0][0]).searchParams, - ); - - expect(updatedUrlQueryParams[paramName]).toBeUndefined(); - }); - }, - ); - }); -}); diff --git a/spec/frontend/releases/components/app_index_spec.js b/spec/frontend/releases/components/app_index_spec.js index 43e88650ae3..63ce4c8bb17 100644 --- a/spec/frontend/releases/components/app_index_spec.js +++ b/spec/frontend/releases/components/app_index_spec.js @@ -1,50 +1,87 @@ -import { shallowMount } from '@vue/test-utils'; -import { merge } from 'lodash'; -import Vue from 'vue'; -import Vuex from 'vuex'; -import { getParameterByName } from '~/lib/utils/url_utility'; -import AppIndex from '~/releases/components/app_index.vue'; +import { cloneDeep } from 'lodash'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import originalAllReleasesQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +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 ReleasesIndexApp from '~/releases/components/app_index.vue'; +import ReleaseBlock from '~/releases/components/release_block.vue'; import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue'; +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'; + +Vue.use(VueApollo); + +jest.mock('~/flash'); + +let mockQueryParams; +jest.mock('~/lib/utils/common_utils', () => ({ + ...jest.requireActual('~/lib/utils/common_utils'), + historyPushState: jest.fn(), +})); jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), - getParameterByName: jest.fn(), + getParameterByName: jest + .fn() + .mockImplementation((parameterName) => mockQueryParams[parameterName]), })); -Vue.use(Vuex); - describe('app_index.vue', () => { + const projectPath = 'project/path'; + const newReleasePath = 'path/to/new/release/page'; + const before = 'beforeCursor'; + const after = 'afterCursor'; + let wrapper; - let fetchReleasesSpy; - let urlParams; - - const createComponent = (storeUpdates) => { - wrapper = shallowMount(AppIndex, { - store: new Vuex.Store({ - modules: { - index: merge( - { - namespaced: true, - actions: { - fetchReleases: fetchReleasesSpy, - }, - state: { - isLoading: true, - releases: [], - }, - }, - storeUpdates, - ), - }, - }), + let allReleases; + let singleRelease; + let noReleases; + let queryMock; + + const createComponent = ({ + singleResponse = Promise.resolve(singleRelease), + fullResponse = Promise.resolve(allReleases), + } = {}) => { + const apolloProvider = createMockApollo([ + [ + allReleasesQuery, + queryMock.mockImplementation((vars) => { + return vars.first === 1 ? singleResponse : fullResponse; + }), + ], + ]); + + wrapper = shallowMountExtended(ReleasesIndexApp, { + apolloProvider, + provide: { + newReleasePath, + projectPath, + }, }); }; beforeEach(() => { - fetchReleasesSpy = jest.fn(); - getParameterByName.mockImplementation((paramName) => urlParams[paramName]); + mockQueryParams = {}; + + allReleases = cloneDeep(originalAllReleasesQueryResponse); + + singleRelease = cloneDeep(originalAllReleasesQueryResponse); + singleRelease.data.project.releases.nodes.splice( + 1, + singleRelease.data.project.releases.nodes.length, + ); + + noReleases = cloneDeep(originalAllReleasesQueryResponse); + noReleases.data.project.releases.nodes = []; + + queryMock = jest.fn(); }); afterEach(() => { @@ -52,120 +89,221 @@ describe('app_index.vue', () => { }); // Finders - const findLoadingIndicator = () => wrapper.find(ReleaseSkeletonLoader); - const findEmptyState = () => wrapper.find('[data-testid="empty-state"]'); - const findSuccessState = () => wrapper.find('[data-testid="success-state"]'); - const findPagination = () => wrapper.find(ReleasesPagination); - const findSortControls = () => wrapper.find(ReleasesSort); - const findNewReleaseButton = () => wrapper.find('[data-testid="new-release-button"]'); - - // Expectations - const expectLoadingIndicator = (shouldExist) => { - it(`${shouldExist ? 'renders' : 'does not render'} a loading indicator`, () => { - expect(findLoadingIndicator().exists()).toBe(shouldExist); - }); - }; + const findLoadingIndicator = () => wrapper.findComponent(ReleaseSkeletonLoader); + const findEmptyState = () => wrapper.findComponent(ReleasesEmptyState); + const findNewReleaseButton = () => wrapper.findByText(ReleasesIndexApp.i18n.newRelease); + const findAllReleaseBlocks = () => wrapper.findAllComponents(ReleaseBlock); + const findPagination = () => wrapper.findComponent(ReleasesPagination); + const findSort = () => wrapper.findComponent(ReleasesSort); - const expectEmptyState = (shouldExist) => { - it(`${shouldExist ? 'renders' : 'does not render'} an empty state`, () => { - expect(findEmptyState().exists()).toBe(shouldExist); - }); - }; + // Tests + describe('component states', () => { + // These need to be defined as functions, since `singleRelease` and + // `allReleases` are generated in a `beforeEach`, and therefore + // aren't available at test definition time. + const getInProgressResponse = () => new Promise(() => {}); + const getErrorResponse = () => Promise.reject(new Error('Oops!')); + const getSingleRequestLoadedResponse = () => Promise.resolve(singleRelease); + const getFullRequestLoadedResponse = () => Promise.resolve(allReleases); + const getLoadedEmptyResponse = () => Promise.resolve(noReleases); + + const toDescription = (bool) => (bool ? 'does' : 'does not'); + + describe.each` + description | singleResponseFn | fullResponseFn | loadingIndicator | emptyState | flashMessage | releaseCount | pagination + ${'both requests loading'} | ${getInProgressResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} + ${'both requests failed'} | ${getErrorResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${0} | ${false} + ${'both requests loaded'} | ${getSingleRequestLoadedResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} + ${'both requests loaded with no results'} | ${getLoadedEmptyResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false} + ${'single request loading, full request loaded'} | ${getInProgressResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} + ${'single request loading, full request failed'} | ${getInProgressResponse} | ${getErrorResponse} | ${true} | ${false} | ${true} | ${0} | ${false} + ${'single request loaded, full request loading'} | ${getSingleRequestLoadedResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${1} | ${false} + ${'single request loaded, full request failed'} | ${getSingleRequestLoadedResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${1} | ${false} + ${'single request failed, full request loading'} | ${getErrorResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} + ${'single request failed, full request loaded'} | ${getErrorResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} + ${'single request loaded with no results, full request loading'} | ${getLoadedEmptyResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} + ${'single request loading, full request loadied with no results'} | ${getInProgressResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false} + `( + '$description', + ({ + singleResponseFn, + fullResponseFn, + loadingIndicator, + emptyState, + flashMessage, + releaseCount, + pagination, + }) => { + beforeEach(() => { + createComponent({ + singleResponse: singleResponseFn(), + fullResponse: fullResponseFn(), + }); + }); + + it(`${toDescription(loadingIndicator)} render a loading indicator`, async () => { + await waitForPromises(); + expect(findLoadingIndicator().exists()).toBe(loadingIndicator); + }); + + it(`${toDescription(emptyState)} render an empty state`, () => { + expect(findEmptyState().exists()).toBe(emptyState); + }); + + it(`${toDescription(flashMessage)} show a flash message`, async () => { + await waitForPromises(); + if (flashMessage) { + expect(createFlash).toHaveBeenCalledWith({ + message: ReleasesIndexApp.i18n.errorMessage, + captureError: true, + error: expect.any(Error), + }); + } else { + expect(createFlash).not.toHaveBeenCalled(); + } + }); + + it(`renders ${releaseCount} release(s)`, () => { + expect(findAllReleaseBlocks()).toHaveLength(releaseCount); + }); + + it(`${toDescription(pagination)} render the pagination controls`, () => { + expect(findPagination().exists()).toBe(pagination); + }); + + it('does render the "New release" button', () => { + expect(findNewReleaseButton().exists()).toBe(true); + }); + + it('does render the sort controls', () => { + expect(findSort().exists()).toBe(true); + }); + }, + ); + }); - const expectSuccessState = (shouldExist) => { - it(`${shouldExist ? 'renders' : 'does not render'} the success state`, () => { - expect(findSuccessState().exists()).toBe(shouldExist); - }); - }; + describe('URL parameters', () => { + describe('when the URL contains no query parameters', () => { + beforeEach(() => { + createComponent(); + }); - const expectPagination = (shouldExist) => { - it(`${shouldExist ? 'renders' : 'does not render'} the pagination controls`, () => { - expect(findPagination().exists()).toBe(shouldExist); - }); - }; + it('makes a request with the correct GraphQL query parameters', () => { + expect(queryMock).toHaveBeenCalledTimes(2); - const expectNewReleaseButton = (shouldExist) => { - it(`${shouldExist ? 'renders' : 'does not render'} the "New release" button`, () => { - expect(findNewReleaseButton().exists()).toBe(shouldExist); - }); - }; + expect(queryMock).toHaveBeenCalledWith({ + first: 1, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); - // Tests - describe('on startup', () => { - it.each` - before | after - ${null} | ${null} - ${'before_param_value'} | ${null} - ${null} | ${'after_param_value'} - `( - 'calls fetchRelease with the correct parameters based on the curent query parameters: before: $before, after: $after', - ({ before, after }) => { - urlParams = { before, after }; + expect(queryMock).toHaveBeenCalledWith({ + first: PAGE_SIZE, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + }); + }); + describe('when the URL contains a "before" query parameter', () => { + beforeEach(() => { + mockQueryParams = { before }; createComponent(); + }); - expect(fetchReleasesSpy).toHaveBeenCalledTimes(1); - expect(fetchReleasesSpy).toHaveBeenCalledWith(expect.anything(), urlParams); - }, - ); - }); + it('makes a request with the correct GraphQL query parameters', () => { + expect(queryMock).toHaveBeenCalledTimes(1); - describe('when the request to fetch releases has not yet completed', () => { - beforeEach(() => { - createComponent(); + expect(queryMock).toHaveBeenCalledWith({ + before, + last: PAGE_SIZE, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + }); }); - expectLoadingIndicator(true); - expectEmptyState(false); - expectSuccessState(false); - expectPagination(false); - }); + describe('when the URL contains an "after" query parameter', () => { + beforeEach(() => { + mockQueryParams = { after }; + createComponent(); + }); - describe('when the request fails', () => { - beforeEach(() => { - createComponent({ - state: { - isLoading: false, - hasError: true, - }, + it('makes a request with the correct GraphQL query parameters', () => { + expect(queryMock).toHaveBeenCalledTimes(2); + + expect(queryMock).toHaveBeenCalledWith({ + after, + first: 1, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + + expect(queryMock).toHaveBeenCalledWith({ + after, + first: PAGE_SIZE, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); }); }); - expectLoadingIndicator(false); - expectEmptyState(false); - expectSuccessState(false); - expectPagination(true); + describe('when the URL contains both "before" and "after" query parameters', () => { + beforeEach(() => { + mockQueryParams = { before, after }; + createComponent(); + }); + + it('ignores the "before" parameter and behaves as if only the "after" parameter was provided', () => { + expect(queryMock).toHaveBeenCalledTimes(2); + + expect(queryMock).toHaveBeenCalledWith({ + after, + first: 1, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + + expect(queryMock).toHaveBeenCalledWith({ + after, + first: PAGE_SIZE, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + }); + }); }); - describe('when the request succeeds but returns no releases', () => { + describe('New release button', () => { beforeEach(() => { - createComponent({ - state: { - isLoading: false, - }, - }); + createComponent(); }); - expectLoadingIndicator(false); - expectEmptyState(true); - expectSuccessState(false); - expectPagination(true); + it('renders the new release button with the correct href', () => { + expect(findNewReleaseButton().attributes().href).toBe(newReleasePath); + }); }); - describe('when the request succeeds and includes at least one release', () => { + describe('pagination', () => { beforeEach(() => { - createComponent({ - state: { - isLoading: false, - releases: [{}], - }, - }); + mockQueryParams = { before }; + createComponent(); }); - expectLoadingIndicator(false); - expectEmptyState(false); - expectSuccessState(true); - expectPagination(true); + it('requeries the GraphQL endpoint when a pagination button is clicked', async () => { + expect(queryMock.mock.calls).toEqual([[expect.objectContaining({ before })]]); + + mockQueryParams = { after }; + findPagination().vm.$emit('next', after); + + await nextTick(); + + expect(queryMock.mock.calls).toEqual([ + [expect.objectContaining({ before })], + [expect.objectContaining({ after })], + [expect.objectContaining({ after })], + ]); + }); }); describe('sorting', () => { @@ -173,59 +311,88 @@ describe('app_index.vue', () => { createComponent(); }); - it('renders the sort controls', () => { - expect(findSortControls().exists()).toBe(true); + it(`sorts by ${DEFAULT_SORT} by default`, () => { + expect(queryMock.mock.calls).toEqual([ + [expect.objectContaining({ sort: DEFAULT_SORT })], + [expect.objectContaining({ sort: DEFAULT_SORT })], + ]); }); - it('calls the fetchReleases store method when the sort is updated', () => { - fetchReleasesSpy.mockClear(); + it('requeries the GraphQL endpoint and updates the URL when the sort is changed', async () => { + findSort().vm.$emit('input', CREATED_ASC); + + await nextTick(); - findSortControls().vm.$emit('sort:changed'); + expect(queryMock.mock.calls).toEqual([ + [expect.objectContaining({ sort: DEFAULT_SORT })], + [expect.objectContaining({ sort: DEFAULT_SORT })], + [expect.objectContaining({ sort: CREATED_ASC })], + [expect.objectContaining({ sort: CREATED_ASC })], + ]); - expect(fetchReleasesSpy).toHaveBeenCalledTimes(1); + // URL manipulation is tested in more detail in the `describe` block below + expect(historyPushState).toHaveBeenCalled(); }); - }); - describe('"New release" button', () => { - describe('when the user is allowed to create releases', () => { - const newReleasePath = 'path/to/new/release/page'; + it('does not requery the GraphQL endpoint or update the URL if the sort is updated to the same value', async () => { + findSort().vm.$emit('input', DEFAULT_SORT); - beforeEach(() => { - createComponent({ state: { newReleasePath } }); - }); + await nextTick(); - expectNewReleaseButton(true); + expect(queryMock.mock.calls).toEqual([ + [expect.objectContaining({ sort: DEFAULT_SORT })], + [expect.objectContaining({ sort: DEFAULT_SORT })], + ]); - it('renders the button with the correct href', () => { - expect(findNewReleaseButton().attributes('href')).toBe(newReleasePath); - }); + expect(historyPushState).not.toHaveBeenCalled(); }); + }); - describe('when the user is not allowed to create releases', () => { - beforeEach(() => { - createComponent(); - }); + describe('sorting + pagination interaction', () => { + const nonPaginationQueryParam = 'nonPaginationQueryParam'; - expectNewReleaseButton(false); + beforeEach(() => { + historyPushState.mockImplementation((newUrl) => { + mockQueryParams = Object.fromEntries(new URL(newUrl).searchParams); + }); }); - }); - describe("when the browser's back button is pressed", () => { - beforeEach(() => { - urlParams = { - before: 'before_param_value', - }; + describe.each` + queryParamsBefore | paramName | paramInitialValue + ${{ before, nonPaginationQueryParam }} | ${'before'} | ${before} + ${{ after, nonPaginationQueryParam }} | ${'after'} | ${after} + `( + 'when the URL contains a "$paramName" pagination cursor', + ({ queryParamsBefore, paramName, paramInitialValue }) => { + beforeEach(async () => { + mockQueryParams = queryParamsBefore; + createComponent(); - createComponent(); + findSort().vm.$emit('input', CREATED_ASC); - fetchReleasesSpy.mockClear(); + await nextTick(); + }); - window.dispatchEvent(new PopStateEvent('popstate')); - }); + it(`resets the page's "${paramName}" pagination cursor when the sort is changed`, () => { + const firstRequestVariables = queryMock.mock.calls[0][0]; + // Might be request #2 or #3, depending on the pagination direction + const mostRecentRequestVariables = + queryMock.mock.calls[queryMock.mock.calls.length - 1][0]; - it('calls the fetchRelease store method with the parameters from the URL query', () => { - expect(fetchReleasesSpy).toHaveBeenCalledTimes(1); - expect(fetchReleasesSpy).toHaveBeenCalledWith(expect.anything(), urlParams); - }); + expect(firstRequestVariables[paramName]).toBe(paramInitialValue); + expect(mostRecentRequestVariables[paramName]).toBeUndefined(); + }); + + it(`updates the URL to not include the "${paramName}" URL query parameter`, () => { + expect(historyPushState).toHaveBeenCalledTimes(1); + + const updatedUrlQueryParams = Object.fromEntries( + new URL(historyPushState.mock.calls[0][0]).searchParams, + ); + + expect(updatedUrlQueryParams[paramName]).toBeUndefined(); + }); + }, + ); }); }); diff --git a/spec/frontend/releases/components/app_show_spec.js b/spec/frontend/releases/components/app_show_spec.js index 41c9746a363..c2ea6900d6e 100644 --- a/spec/frontend/releases/components/app_show_spec.js +++ b/spec/frontend/releases/components/app_show_spec.js @@ -143,6 +143,12 @@ describe('Release show component', () => { describe('when the request succeeded, but the returned "project.release" key was null', () => { beforeEach(async () => { + // As we return a release as `null`, Apollo also throws an error to the console + // about the missing field. We need to suppress console.error in order to check + // that flash message was called + + // eslint-disable-next-line no-console + console.error = jest.fn(); const apolloProvider = createMockApollo([ [ oneReleaseQuery, diff --git a/spec/frontend/releases/components/releases_pagination_apollo_client_spec.js b/spec/frontend/releases/components/releases_pagination_apollo_client_spec.js deleted file mode 100644 index a538afd5d38..00000000000 --- a/spec/frontend/releases/components/releases_pagination_apollo_client_spec.js +++ /dev/null @@ -1,126 +0,0 @@ -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import { historyPushState } from '~/lib/utils/common_utils'; -import ReleasesPaginationApolloClient from '~/releases/components/releases_pagination_apollo_client.vue'; - -jest.mock('~/lib/utils/common_utils', () => ({ - ...jest.requireActual('~/lib/utils/common_utils'), - historyPushState: jest.fn(), -})); - -describe('releases_pagination_apollo_client.vue', () => { - const startCursor = 'startCursor'; - const endCursor = 'endCursor'; - let wrapper; - let onPrev; - let onNext; - - const createComponent = (pageInfo) => { - onPrev = jest.fn(); - onNext = jest.fn(); - - wrapper = mountExtended(ReleasesPaginationApolloClient, { - propsData: { - pageInfo, - }, - listeners: { - prev: onPrev, - next: onNext, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - const singlePageInfo = { - hasPreviousPage: false, - hasNextPage: false, - startCursor, - endCursor, - }; - - const onlyNextPageInfo = { - hasPreviousPage: false, - hasNextPage: true, - startCursor, - endCursor, - }; - - const onlyPrevPageInfo = { - hasPreviousPage: true, - hasNextPage: false, - startCursor, - endCursor, - }; - - const prevAndNextPageInfo = { - hasPreviousPage: true, - hasNextPage: true, - startCursor, - endCursor, - }; - - const findPrevButton = () => wrapper.findByTestId('prevButton'); - const findNextButton = () => wrapper.findByTestId('nextButton'); - - describe.each` - description | pageInfo | prevEnabled | nextEnabled - ${'when there is only one page of results'} | ${singlePageInfo} | ${false} | ${false} - ${'when there is a next page, but not a previous page'} | ${onlyNextPageInfo} | ${false} | ${true} - ${'when there is a previous page, but not a next page'} | ${onlyPrevPageInfo} | ${true} | ${false} - ${'when there is both a previous and next page'} | ${prevAndNextPageInfo} | ${true} | ${true} - `('component states', ({ description, pageInfo, prevEnabled, nextEnabled }) => { - describe(description, () => { - beforeEach(() => { - createComponent(pageInfo); - }); - - it(`renders the "Prev" button as ${prevEnabled ? 'enabled' : 'disabled'}`, () => { - expect(findPrevButton().attributes().disabled).toBe(prevEnabled ? undefined : 'disabled'); - }); - - it(`renders the "Next" button as ${nextEnabled ? 'enabled' : 'disabled'}`, () => { - expect(findNextButton().attributes().disabled).toBe(nextEnabled ? undefined : 'disabled'); - }); - }); - }); - - describe('button behavior', () => { - beforeEach(() => { - createComponent(prevAndNextPageInfo); - }); - - describe('next button behavior', () => { - beforeEach(() => { - findNextButton().trigger('click'); - }); - - it('emits an "next" event with the "after" cursor', () => { - expect(onNext.mock.calls).toEqual([[endCursor]]); - }); - - it('calls historyPushState with the new URL', () => { - expect(historyPushState.mock.calls).toEqual([ - [expect.stringContaining(`?after=${endCursor}`)], - ]); - }); - }); - - describe('prev button behavior', () => { - beforeEach(() => { - findPrevButton().trigger('click'); - }); - - it('emits an "prev" event with the "before" cursor', () => { - expect(onPrev.mock.calls).toEqual([[startCursor]]); - }); - - it('calls historyPushState with the new URL', () => { - expect(historyPushState.mock.calls).toEqual([ - [expect.stringContaining(`?before=${startCursor}`)], - ]); - }); - }); - }); -}); diff --git a/spec/frontend/releases/components/releases_pagination_spec.js b/spec/frontend/releases/components/releases_pagination_spec.js index b8c69b0ea70..59be808c802 100644 --- a/spec/frontend/releases/components/releases_pagination_spec.js +++ b/spec/frontend/releases/components/releases_pagination_spec.js @@ -1,140 +1,94 @@ -import { GlKeysetPagination } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import Vue from 'vue'; -import Vuex from 'vuex'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { historyPushState } from '~/lib/utils/common_utils'; import ReleasesPagination from '~/releases/components/releases_pagination.vue'; -import createStore from '~/releases/stores'; -import createIndexModule from '~/releases/stores/modules/index'; jest.mock('~/lib/utils/common_utils', () => ({ ...jest.requireActual('~/lib/utils/common_utils'), historyPushState: jest.fn(), })); -Vue.use(Vuex); - -describe('~/releases/components/releases_pagination.vue', () => { +describe('releases_pagination.vue', () => { + const startCursor = 'startCursor'; + const endCursor = 'endCursor'; let wrapper; - let indexModule; - - const cursors = { - startCursor: 'startCursor', - endCursor: 'endCursor', - }; - - const projectPath = 'my/project'; + let onPrev; + let onNext; const createComponent = (pageInfo) => { - indexModule = createIndexModule({ projectPath }); - - indexModule.state.pageInfo = pageInfo; - - indexModule.actions.fetchReleases = jest.fn(); - - wrapper = mount(ReleasesPagination, { - store: createStore({ - modules: { - index: indexModule, - }, - featureFlags: {}, - }), + onPrev = jest.fn(); + onNext = jest.fn(); + + wrapper = mountExtended(ReleasesPagination, { + propsData: { + pageInfo, + }, + listeners: { + prev: onPrev, + next: onNext, + }, }); }; afterEach(() => { wrapper.destroy(); - wrapper = null; }); - const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination); - const findPrevButton = () => findGlKeysetPagination().find('[data-testid="prevButton"]'); - const findNextButton = () => findGlKeysetPagination().find('[data-testid="nextButton"]'); - - const expectDisabledPrev = () => { - expect(findPrevButton().attributes().disabled).toBe('disabled'); + const singlePageInfo = { + hasPreviousPage: false, + hasNextPage: false, + startCursor, + endCursor, }; - const expectEnabledPrev = () => { - expect(findPrevButton().attributes().disabled).toBe(undefined); + + const onlyNextPageInfo = { + hasPreviousPage: false, + hasNextPage: true, + startCursor, + endCursor, }; - const expectDisabledNext = () => { - expect(findNextButton().attributes().disabled).toBe('disabled'); + + const onlyPrevPageInfo = { + hasPreviousPage: true, + hasNextPage: false, + startCursor, + endCursor, }; - const expectEnabledNext = () => { - expect(findNextButton().attributes().disabled).toBe(undefined); + + const prevAndNextPageInfo = { + hasPreviousPage: true, + hasNextPage: true, + startCursor, + endCursor, }; - describe('when there is only one page of results', () => { - beforeEach(() => { - createComponent({ - hasPreviousPage: false, - hasNextPage: false, + const findPrevButton = () => wrapper.findByTestId('prevButton'); + const findNextButton = () => wrapper.findByTestId('nextButton'); + + describe.each` + description | pageInfo | prevEnabled | nextEnabled + ${'when there is only one page of results'} | ${singlePageInfo} | ${false} | ${false} + ${'when there is a next page, but not a previous page'} | ${onlyNextPageInfo} | ${false} | ${true} + ${'when there is a previous page, but not a next page'} | ${onlyPrevPageInfo} | ${true} | ${false} + ${'when there is both a previous and next page'} | ${prevAndNextPageInfo} | ${true} | ${true} + `('component states', ({ description, pageInfo, prevEnabled, nextEnabled }) => { + describe(description, () => { + beforeEach(() => { + createComponent(pageInfo); }); - }); - - it('does not render a GlKeysetPagination', () => { - expect(findGlKeysetPagination().exists()).toBe(false); - }); - }); - describe('when there is a next page, but not a previous page', () => { - beforeEach(() => { - createComponent({ - hasPreviousPage: false, - hasNextPage: true, + it(`renders the "Prev" button as ${prevEnabled ? 'enabled' : 'disabled'}`, () => { + expect(findPrevButton().attributes().disabled).toBe(prevEnabled ? undefined : 'disabled'); }); - }); - - it('renders a disabled "Prev" button', () => { - expectDisabledPrev(); - }); - it('renders an enabled "Next" button', () => { - expectEnabledNext(); - }); - }); - - describe('when there is a previous page, but not a next page', () => { - beforeEach(() => { - createComponent({ - hasPreviousPage: true, - hasNextPage: false, - }); - }); - - it('renders a enabled "Prev" button', () => { - expectEnabledPrev(); - }); - - it('renders an disabled "Next" button', () => { - expectDisabledNext(); - }); - }); - - describe('when there is both a previous page and a next page', () => { - beforeEach(() => { - createComponent({ - hasPreviousPage: true, - hasNextPage: true, + it(`renders the "Next" button as ${nextEnabled ? 'enabled' : 'disabled'}`, () => { + expect(findNextButton().attributes().disabled).toBe(nextEnabled ? undefined : 'disabled'); }); }); - - it('renders a enabled "Prev" button', () => { - expectEnabledPrev(); - }); - - it('renders an enabled "Next" button', () => { - expectEnabledNext(); - }); }); describe('button behavior', () => { beforeEach(() => { - createComponent({ - hasPreviousPage: true, - hasNextPage: true, - ...cursors, - }); + createComponent(prevAndNextPageInfo); }); describe('next button behavior', () => { @@ -142,33 +96,29 @@ describe('~/releases/components/releases_pagination.vue', () => { findNextButton().trigger('click'); }); - it('calls fetchReleases with the correct after cursor', () => { - expect(indexModule.actions.fetchReleases.mock.calls).toEqual([ - [expect.anything(), { after: cursors.endCursor }], - ]); + it('emits an "next" event with the "after" cursor', () => { + expect(onNext.mock.calls).toEqual([[endCursor]]); }); it('calls historyPushState with the new URL', () => { expect(historyPushState.mock.calls).toEqual([ - [expect.stringContaining(`?after=${cursors.endCursor}`)], + [expect.stringContaining(`?after=${endCursor}`)], ]); }); }); - describe('previous button behavior', () => { + describe('prev button behavior', () => { beforeEach(() => { findPrevButton().trigger('click'); }); - it('calls fetchReleases with the correct before cursor', () => { - expect(indexModule.actions.fetchReleases.mock.calls).toEqual([ - [expect.anything(), { before: cursors.startCursor }], - ]); + it('emits an "prev" event with the "before" cursor', () => { + expect(onPrev.mock.calls).toEqual([[startCursor]]); }); it('calls historyPushState with the new URL', () => { expect(historyPushState.mock.calls).toEqual([ - [expect.stringContaining(`?before=${cursors.startCursor}`)], + [expect.stringContaining(`?before=${startCursor}`)], ]); }); }); diff --git a/spec/frontend/releases/components/releases_sort_apollo_client_spec.js b/spec/frontend/releases/components/releases_sort_apollo_client_spec.js deleted file mode 100644 index d93a932af01..00000000000 --- a/spec/frontend/releases/components/releases_sort_apollo_client_spec.js +++ /dev/null @@ -1,103 +0,0 @@ -import { GlSorting, GlSortingItem } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import ReleasesSortApolloClient from '~/releases/components/releases_sort_apollo_client.vue'; -import { RELEASED_AT_ASC, RELEASED_AT_DESC, CREATED_ASC, CREATED_DESC } from '~/releases/constants'; - -describe('releases_sort_apollo_client.vue', () => { - let wrapper; - - const createComponent = (valueProp = RELEASED_AT_ASC) => { - wrapper = shallowMountExtended(ReleasesSortApolloClient, { - propsData: { - value: valueProp, - }, - stubs: { - GlSortingItem, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - const findSorting = () => wrapper.findComponent(GlSorting); - const findSortingItems = () => wrapper.findAllComponents(GlSortingItem); - const findReleasedDateItem = () => - findSortingItems().wrappers.find((item) => item.text() === 'Released date'); - const findCreatedDateItem = () => - findSortingItems().wrappers.find((item) => item.text() === 'Created date'); - const getSortingItemsInfo = () => - findSortingItems().wrappers.map((item) => ({ - label: item.text(), - active: item.attributes().active === 'true', - })); - - describe.each` - valueProp | text | isAscending | items - ${RELEASED_AT_ASC} | ${'Released date'} | ${true} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]} - ${RELEASED_AT_DESC} | ${'Released date'} | ${false} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]} - ${CREATED_ASC} | ${'Created date'} | ${true} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]} - ${CREATED_DESC} | ${'Created date'} | ${false} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]} - `('component states', ({ valueProp, text, isAscending, items }) => { - beforeEach(() => { - createComponent(valueProp); - }); - - it(`when the sort is ${valueProp}, provides the GlSorting with the props text="${text}" and isAscending=${isAscending}`, () => { - expect(findSorting().props()).toEqual( - expect.objectContaining({ - text, - isAscending, - }), - ); - }); - - it(`when the sort is ${valueProp}, renders the expected dropdown items`, () => { - expect(getSortingItemsInfo()).toEqual(items); - }); - }); - - const clickReleasedDateItem = () => findReleasedDateItem().vm.$emit('click'); - const clickCreatedDateItem = () => findCreatedDateItem().vm.$emit('click'); - const clickSortDirectionButton = () => findSorting().vm.$emit('sortDirectionChange'); - - const releasedAtDropdownItemDescription = 'released at dropdown item'; - const createdAtDropdownItemDescription = 'created at dropdown item'; - const sortDirectionButtonDescription = 'sort direction button'; - - describe.each` - initialValueProp | itemClickFn | itemToClickDescription | emittedEvent - ${RELEASED_AT_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined} - ${RELEASED_AT_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_ASC} - ${RELEASED_AT_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_DESC} - ${RELEASED_AT_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined} - ${RELEASED_AT_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_DESC} - ${RELEASED_AT_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_ASC} - ${CREATED_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_ASC} - ${CREATED_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined} - ${CREATED_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_DESC} - ${CREATED_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_DESC} - ${CREATED_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined} - ${CREATED_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_ASC} - `('input event', ({ initialValueProp, itemClickFn, itemToClickDescription, emittedEvent }) => { - beforeEach(() => { - createComponent(initialValueProp); - itemClickFn(); - }); - - it(`emits ${ - emittedEvent || 'nothing' - } when value prop is ${initialValueProp} and the ${itemToClickDescription} is clicked`, () => { - expect(wrapper.emitted().input?.[0]?.[0]).toEqual(emittedEvent); - }); - }); - - describe('prop validation', () => { - it('validates that the `value` prop is one of the expected sort strings', () => { - expect(() => { - createComponent('not a valid value'); - }).toThrow('Invalid prop: custom validator check failed'); - }); - }); -}); diff --git a/spec/frontend/releases/components/releases_sort_spec.js b/spec/frontend/releases/components/releases_sort_spec.js index 7774532bc12..c6e1846d252 100644 --- a/spec/frontend/releases/components/releases_sort_spec.js +++ b/spec/frontend/releases/components/releases_sort_spec.js @@ -1,65 +1,103 @@ import { GlSorting, GlSortingItem } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import Vuex from 'vuex'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ReleasesSort from '~/releases/components/releases_sort.vue'; -import createStore from '~/releases/stores'; -import createIndexModule from '~/releases/stores/modules/index'; +import { RELEASED_AT_ASC, RELEASED_AT_DESC, CREATED_ASC, CREATED_DESC } from '~/releases/constants'; -Vue.use(Vuex); - -describe('~/releases/components/releases_sort.vue', () => { +describe('releases_sort.vue', () => { let wrapper; - let store; - let indexModule; - const projectId = 8; - - const createComponent = () => { - indexModule = createIndexModule({ projectId }); - store = createStore({ - modules: { - index: indexModule, + const createComponent = (valueProp = RELEASED_AT_ASC) => { + wrapper = shallowMountExtended(ReleasesSort, { + propsData: { + value: valueProp, }, - }); - - store.dispatch = jest.fn(); - - wrapper = shallowMount(ReleasesSort, { - store, stubs: { GlSortingItem, }, }); }; - const findReleasesSorting = () => wrapper.find(GlSorting); - const findSortingItems = () => wrapper.findAll(GlSortingItem); - afterEach(() => { wrapper.destroy(); - wrapper = null; }); - beforeEach(() => { - createComponent(); - }); + const findSorting = () => wrapper.findComponent(GlSorting); + const findSortingItems = () => wrapper.findAllComponents(GlSortingItem); + const findReleasedDateItem = () => + findSortingItems().wrappers.find((item) => item.text() === 'Released date'); + const findCreatedDateItem = () => + findSortingItems().wrappers.find((item) => item.text() === 'Created date'); + const getSortingItemsInfo = () => + findSortingItems().wrappers.map((item) => ({ + label: item.text(), + active: item.attributes().active === 'true', + })); + + describe.each` + valueProp | text | isAscending | items + ${RELEASED_AT_ASC} | ${'Released date'} | ${true} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]} + ${RELEASED_AT_DESC} | ${'Released date'} | ${false} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]} + ${CREATED_ASC} | ${'Created date'} | ${true} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]} + ${CREATED_DESC} | ${'Created date'} | ${false} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]} + `('component states', ({ valueProp, text, isAscending, items }) => { + beforeEach(() => { + createComponent(valueProp); + }); - it('has all the sortable items', () => { - expect(findSortingItems()).toHaveLength(wrapper.vm.sortOptions.length); + it(`when the sort is ${valueProp}, provides the GlSorting with the props text="${text}" and isAscending=${isAscending}`, () => { + expect(findSorting().props()).toEqual( + expect.objectContaining({ + text, + isAscending, + }), + ); + }); + + it(`when the sort is ${valueProp}, renders the expected dropdown items`, () => { + expect(getSortingItemsInfo()).toEqual(items); + }); }); - it('on sort change set sorting in vuex and emit event', () => { - findReleasesSorting().vm.$emit('sortDirectionChange'); - expect(store.dispatch).toHaveBeenCalledWith('index/setSorting', { sort: 'asc' }); - expect(wrapper.emitted('sort:changed')).toBeTruthy(); + const clickReleasedDateItem = () => findReleasedDateItem().vm.$emit('click'); + const clickCreatedDateItem = () => findCreatedDateItem().vm.$emit('click'); + const clickSortDirectionButton = () => findSorting().vm.$emit('sortDirectionChange'); + + const releasedAtDropdownItemDescription = 'released at dropdown item'; + const createdAtDropdownItemDescription = 'created at dropdown item'; + const sortDirectionButtonDescription = 'sort direction button'; + + describe.each` + initialValueProp | itemClickFn | itemToClickDescription | emittedEvent + ${RELEASED_AT_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined} + ${RELEASED_AT_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_ASC} + ${RELEASED_AT_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_DESC} + ${RELEASED_AT_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined} + ${RELEASED_AT_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_DESC} + ${RELEASED_AT_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_ASC} + ${CREATED_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_ASC} + ${CREATED_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined} + ${CREATED_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_DESC} + ${CREATED_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_DESC} + ${CREATED_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined} + ${CREATED_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_ASC} + `('input event', ({ initialValueProp, itemClickFn, itemToClickDescription, emittedEvent }) => { + beforeEach(() => { + createComponent(initialValueProp); + itemClickFn(); + }); + + it(`emits ${ + emittedEvent || 'nothing' + } when value prop is ${initialValueProp} and the ${itemToClickDescription} is clicked`, () => { + expect(wrapper.emitted().input?.[0]?.[0]).toEqual(emittedEvent); + }); }); - it('on sort item click set sorting and emit event', () => { - const item = findSortingItems().at(0); - const { orderBy } = wrapper.vm.sortOptions[0]; - item.vm.$emit('click'); - expect(store.dispatch).toHaveBeenCalledWith('index/setSorting', { orderBy }); - expect(wrapper.emitted('sort:changed')).toBeTruthy(); + describe('prop validation', () => { + it('validates that the `value` prop is one of the expected sort strings', () => { + expect(() => { + createComponent('not a valid value'); + }).toThrow('Invalid prop: custom validator check failed'); + }); }); }); diff --git a/spec/frontend/releases/stores/modules/list/actions_spec.js b/spec/frontend/releases/stores/modules/list/actions_spec.js deleted file mode 100644 index 91406f7e2f4..00000000000 --- a/spec/frontend/releases/stores/modules/list/actions_spec.js +++ /dev/null @@ -1,197 +0,0 @@ -import { cloneDeep } from 'lodash'; -import originalGraphqlReleasesResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json'; -import testAction from 'helpers/vuex_action_helper'; -import { PAGE_SIZE } from '~/releases/constants'; -import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; -import { - fetchReleases, - receiveReleasesError, - setSorting, -} from '~/releases/stores/modules/index/actions'; -import * as types from '~/releases/stores/modules/index/mutation_types'; -import createState from '~/releases/stores/modules/index/state'; -import { gqClient, convertAllReleasesGraphQLResponse } from '~/releases/util'; - -describe('Releases State actions', () => { - let mockedState; - let graphqlReleasesResponse; - - const projectPath = 'root/test-project'; - const projectId = 19; - const before = 'testBeforeCursor'; - const after = 'testAfterCursor'; - - beforeEach(() => { - mockedState = { - ...createState({ - projectId, - projectPath, - }), - }; - - graphqlReleasesResponse = cloneDeep(originalGraphqlReleasesResponse); - }); - - describe('fetchReleases', () => { - describe('GraphQL query variables', () => { - let vuexParams; - - beforeEach(() => { - jest.spyOn(gqClient, 'query'); - - vuexParams = { dispatch: jest.fn(), commit: jest.fn(), state: mockedState }; - }); - - describe('when neither a before nor an after parameter is provided', () => { - beforeEach(() => { - fetchReleases(vuexParams, { before: undefined, after: undefined }); - }); - - it('makes a GraphQl query with a first variable', () => { - expect(gqClient.query).toHaveBeenCalledWith({ - query: allReleasesQuery, - variables: { fullPath: projectPath, first: PAGE_SIZE, sort: 'RELEASED_AT_DESC' }, - }); - }); - }); - - describe('when only a before parameter is provided', () => { - beforeEach(() => { - fetchReleases(vuexParams, { before, after: undefined }); - }); - - it('makes a GraphQl query with last and before variables', () => { - expect(gqClient.query).toHaveBeenCalledWith({ - query: allReleasesQuery, - variables: { fullPath: projectPath, last: PAGE_SIZE, before, sort: 'RELEASED_AT_DESC' }, - }); - }); - }); - - describe('when only an after parameter is provided', () => { - beforeEach(() => { - fetchReleases(vuexParams, { before: undefined, after }); - }); - - it('makes a GraphQl query with first and after variables', () => { - expect(gqClient.query).toHaveBeenCalledWith({ - query: allReleasesQuery, - variables: { fullPath: projectPath, first: PAGE_SIZE, after, sort: 'RELEASED_AT_DESC' }, - }); - }); - }); - - describe('when both before and after parameters are provided', () => { - it('throws an error', () => { - const callFetchReleases = () => { - fetchReleases(vuexParams, { before, after }); - }; - - expect(callFetchReleases).toThrowError( - 'Both a `before` and an `after` parameter were provided to fetchReleases. These parameters cannot be used together.', - ); - }); - }); - - describe('when the sort parameters are provided', () => { - it.each` - sort | orderBy | ReleaseSort - ${'asc'} | ${'released_at'} | ${'RELEASED_AT_ASC'} - ${'desc'} | ${'released_at'} | ${'RELEASED_AT_DESC'} - ${'asc'} | ${'created_at'} | ${'CREATED_ASC'} - ${'desc'} | ${'created_at'} | ${'CREATED_DESC'} - `( - 'correctly sets $ReleaseSort based on $sort and $orderBy', - ({ sort, orderBy, ReleaseSort }) => { - mockedState.sorting.sort = sort; - mockedState.sorting.orderBy = orderBy; - - fetchReleases(vuexParams, { before: undefined, after: undefined }); - - expect(gqClient.query).toHaveBeenCalledWith({ - query: allReleasesQuery, - variables: { fullPath: projectPath, first: PAGE_SIZE, sort: ReleaseSort }, - }); - }, - ); - }); - }); - - describe('when the request is successful', () => { - beforeEach(() => { - jest.spyOn(gqClient, 'query').mockResolvedValue(graphqlReleasesResponse); - }); - - it(`commits ${types.REQUEST_RELEASES} and ${types.RECEIVE_RELEASES_SUCCESS}`, () => { - const convertedResponse = convertAllReleasesGraphQLResponse(graphqlReleasesResponse); - - return testAction( - fetchReleases, - {}, - mockedState, - [ - { - type: types.REQUEST_RELEASES, - }, - { - type: types.RECEIVE_RELEASES_SUCCESS, - payload: { - data: convertedResponse.data, - pageInfo: convertedResponse.paginationInfo, - }, - }, - ], - [], - ); - }); - }); - - describe('when the request fails', () => { - beforeEach(() => { - jest.spyOn(gqClient, 'query').mockRejectedValue(new Error('Something went wrong!')); - }); - - it(`commits ${types.REQUEST_RELEASES} and dispatch receiveReleasesError`, () => { - return testAction( - fetchReleases, - {}, - mockedState, - [ - { - type: types.REQUEST_RELEASES, - }, - ], - [ - { - type: 'receiveReleasesError', - }, - ], - ); - }); - }); - }); - - describe('receiveReleasesError', () => { - it('should commit RECEIVE_RELEASES_ERROR mutation', () => { - return testAction( - receiveReleasesError, - null, - mockedState, - [{ type: types.RECEIVE_RELEASES_ERROR }], - [], - ); - }); - }); - - describe('setSorting', () => { - it('should commit SET_SORTING', () => { - return testAction( - setSorting, - { orderBy: 'released_at', sort: 'asc' }, - null, - [{ type: types.SET_SORTING, payload: { orderBy: 'released_at', sort: 'asc' } }], - [], - ); - }); - }); -}); diff --git a/spec/frontend/releases/stores/modules/list/helpers.js b/spec/frontend/releases/stores/modules/list/helpers.js deleted file mode 100644 index 6669f44aa95..00000000000 --- a/spec/frontend/releases/stores/modules/list/helpers.js +++ /dev/null @@ -1,5 +0,0 @@ -import state from '~/releases/stores/modules/index/state'; - -export const resetStore = (store) => { - store.replaceState(state()); -}; diff --git a/spec/frontend/releases/stores/modules/list/mutations_spec.js b/spec/frontend/releases/stores/modules/list/mutations_spec.js deleted file mode 100644 index 49e324c28a5..00000000000 --- a/spec/frontend/releases/stores/modules/list/mutations_spec.js +++ /dev/null @@ -1,81 +0,0 @@ -import originalRelease from 'test_fixtures/api/releases/release.json'; -import graphqlReleasesResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import * as types from '~/releases/stores/modules/index/mutation_types'; -import mutations from '~/releases/stores/modules/index/mutations'; -import createState from '~/releases/stores/modules/index/state'; -import { convertAllReleasesGraphQLResponse } from '~/releases/util'; - -const originalReleases = [originalRelease]; - -describe('Releases Store Mutations', () => { - let stateCopy; - let pageInfo; - let releases; - - beforeEach(() => { - stateCopy = createState({}); - pageInfo = convertAllReleasesGraphQLResponse(graphqlReleasesResponse).paginationInfo; - releases = convertObjectPropsToCamelCase(originalReleases, { deep: true }); - }); - - describe('REQUEST_RELEASES', () => { - it('sets isLoading to true', () => { - mutations[types.REQUEST_RELEASES](stateCopy); - - expect(stateCopy.isLoading).toEqual(true); - }); - }); - - describe('RECEIVE_RELEASES_SUCCESS', () => { - beforeEach(() => { - mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, { - pageInfo, - data: releases, - }); - }); - - it('sets is loading to false', () => { - expect(stateCopy.isLoading).toEqual(false); - }); - - it('sets hasError to false', () => { - expect(stateCopy.hasError).toEqual(false); - }); - - it('sets data', () => { - expect(stateCopy.releases).toEqual(releases); - }); - - it('sets pageInfo', () => { - expect(stateCopy.pageInfo).toEqual(pageInfo); - }); - }); - - describe('RECEIVE_RELEASES_ERROR', () => { - it('resets data', () => { - mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, { - pageInfo, - data: releases, - }); - - mutations[types.RECEIVE_RELEASES_ERROR](stateCopy); - - expect(stateCopy.isLoading).toEqual(false); - expect(stateCopy.releases).toEqual([]); - expect(stateCopy.pageInfo).toEqual({}); - }); - }); - - describe('SET_SORTING', () => { - it('should merge the sorting object with sort value', () => { - mutations[types.SET_SORTING](stateCopy, { sort: 'asc' }); - expect(stateCopy.sorting).toEqual({ ...stateCopy.sorting, sort: 'asc' }); - }); - - it('should merge the sorting object with order_by value', () => { - mutations[types.SET_SORTING](stateCopy, { orderBy: 'created_at' }); - expect(stateCopy.sorting).toEqual({ ...stateCopy.sorting, orderBy: 'created_at' }); - }); - }); -}); diff --git a/spec/frontend/reports/accessibility_report/store/actions_spec.js b/spec/frontend/reports/accessibility_report/store/actions_spec.js index 46dbe1ff7a1..bab6c4905a7 100644 --- a/spec/frontend/reports/accessibility_report/store/actions_spec.js +++ b/spec/frontend/reports/accessibility_report/store/actions_spec.js @@ -17,16 +17,15 @@ describe('Accessibility Reports actions', () => { }); describe('setEndpoints', () => { - it('should commit SET_ENDPOINTS mutation', (done) => { + it('should commit SET_ENDPOINTS mutation', () => { const endpoint = 'endpoint.json'; - testAction( + return testAction( actions.setEndpoint, endpoint, localState, [{ type: types.SET_ENDPOINT, payload: endpoint }], [], - done, ); }); }); @@ -46,11 +45,11 @@ describe('Accessibility Reports actions', () => { }); describe('success', () => { - it('should commit REQUEST_REPORT mutation and dispatch receiveReportSuccess', (done) => { + it('should commit REQUEST_REPORT mutation and dispatch receiveReportSuccess', () => { const data = { report: { summary: {} } }; mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, data); - testAction( + return testAction( actions.fetchReport, null, localState, @@ -61,60 +60,55 @@ describe('Accessibility Reports actions', () => { type: 'receiveReportSuccess', }, ], - done, ); }); }); describe('error', () => { - it('should commit REQUEST_REPORT and RECEIVE_REPORT_ERROR mutations', (done) => { + it('should commit REQUEST_REPORT and RECEIVE_REPORT_ERROR mutations', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500); - testAction( + return testAction( actions.fetchReport, null, localState, [{ type: types.REQUEST_REPORT }], [{ type: 'receiveReportError' }], - done, ); }); }); }); describe('receiveReportSuccess', () => { - it('should commit RECEIVE_REPORT_SUCCESS mutation with 200', (done) => { - testAction( + it('should commit RECEIVE_REPORT_SUCCESS mutation with 200', () => { + return testAction( actions.receiveReportSuccess, { status: 200, data: mockReport }, localState, [{ type: types.RECEIVE_REPORT_SUCCESS, payload: mockReport }], [{ type: 'stopPolling' }], - done, ); }); - it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', (done) => { - testAction( + it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', () => { + return testAction( actions.receiveReportSuccess, { status: 204, data: mockReport }, localState, [], [], - done, ); }); }); describe('receiveReportError', () => { - it('should commit RECEIVE_REPORT_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_REPORT_ERROR mutation', () => { + return testAction( actions.receiveReportError, null, localState, [{ type: types.RECEIVE_REPORT_ERROR }], [{ type: 'stopPolling' }], - done, ); }); }); diff --git a/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js b/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js index c548007a8a6..17f07ac2b8f 100644 --- a/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js +++ b/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js @@ -51,6 +51,7 @@ describe('code quality issue body issue body', () => { ${'blocker'} | ${'text-danger-800'} | ${'severity-critical'} ${'unknown'} | ${'text-secondary-400'} | ${'severity-unknown'} ${'invalid'} | ${'text-secondary-400'} | ${'severity-unknown'} + ${undefined} | ${'text-secondary-400'} | ${'severity-unknown'} `( 'renders correct icon for "$severity" severity rating', ({ severity, iconClass, iconName }) => { diff --git a/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js b/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js index 1f923f41274..b61b65c2713 100644 --- a/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js +++ b/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js @@ -135,7 +135,7 @@ describe('Grouped code quality reports app', () => { }); it('does not render a help icon', () => { - expect(findWidget().find('[data-testid="question-icon"]').exists()).toBe(false); + expect(findWidget().find('[data-testid="question-o-icon"]').exists()).toBe(false); }); describe('when base report was not found', () => { @@ -144,7 +144,7 @@ describe('Grouped code quality reports app', () => { }); it('renders a help icon with more information', () => { - expect(findWidget().find('[data-testid="question-icon"]').exists()).toBe(true); + expect(findWidget().find('[data-testid="question-o-icon"]').exists()).toBe(true); }); }); }); diff --git a/spec/frontend/reports/codequality_report/store/actions_spec.js b/spec/frontend/reports/codequality_report/store/actions_spec.js index 1821390786b..71f1a0f4de0 100644 --- a/spec/frontend/reports/codequality_report/store/actions_spec.js +++ b/spec/frontend/reports/codequality_report/store/actions_spec.js @@ -23,7 +23,7 @@ describe('Codequality Reports actions', () => { }); describe('setPaths', () => { - it('should commit SET_PATHS mutation', (done) => { + it('should commit SET_PATHS mutation', () => { const paths = { baseBlobPath: 'baseBlobPath', headBlobPath: 'headBlobPath', @@ -31,13 +31,12 @@ describe('Codequality Reports actions', () => { helpPath: 'codequalityHelpPath', }; - testAction( + return testAction( actions.setPaths, paths, localState, [{ type: types.SET_PATHS, payload: paths }], [], - done, ); }); }); @@ -56,10 +55,10 @@ describe('Codequality Reports actions', () => { }); describe('on success', () => { - it('commits REQUEST_REPORTS and dispatches receiveReportsSuccess', (done) => { + it('commits REQUEST_REPORTS and dispatches receiveReportsSuccess', () => { mock.onGet(endpoint).reply(200, reportIssues); - testAction( + return testAction( actions.fetchReports, null, localState, @@ -70,51 +69,48 @@ describe('Codequality Reports actions', () => { type: 'receiveReportsSuccess', }, ], - done, ); }); }); describe('on error', () => { - it('commits REQUEST_REPORTS and dispatches receiveReportsError', (done) => { + it('commits REQUEST_REPORTS and dispatches receiveReportsError', () => { mock.onGet(endpoint).reply(500); - testAction( + return testAction( actions.fetchReports, null, localState, [{ type: types.REQUEST_REPORTS }], [{ type: 'receiveReportsError', payload: expect.any(Error) }], - done, ); }); }); describe('when base report is not found', () => { - it('commits REQUEST_REPORTS and dispatches receiveReportsError', (done) => { + it('commits REQUEST_REPORTS and dispatches receiveReportsError', () => { const data = { status: STATUS_NOT_FOUND }; mock.onGet(`${TEST_HOST}/codequality_reports.json`).reply(200, data); - testAction( + return testAction( actions.fetchReports, null, localState, [{ type: types.REQUEST_REPORTS }], [{ type: 'receiveReportsError', payload: data }], - done, ); }); }); describe('while waiting for report results', () => { - it('continues polling until it receives data', (done) => { + it('continues polling until it receives data', () => { mock .onGet(endpoint) .replyOnce(204, undefined, pollIntervalHeader) .onGet(endpoint) .reply(200, reportIssues); - Promise.all([ + return Promise.all([ testAction( actions.fetchReports, null, @@ -126,7 +122,6 @@ describe('Codequality Reports actions', () => { type: 'receiveReportsSuccess', }, ], - done, ), axios // wait for initial NO_CONTENT response to be fulfilled @@ -134,24 +129,23 @@ describe('Codequality Reports actions', () => { .then(() => { jest.advanceTimersByTime(pollInterval); }), - ]).catch(done.fail); + ]); }); - it('continues polling until it receives an error', (done) => { + it('continues polling until it receives an error', () => { mock .onGet(endpoint) .replyOnce(204, undefined, pollIntervalHeader) .onGet(endpoint) .reply(500); - Promise.all([ + return Promise.all([ testAction( actions.fetchReports, null, localState, [{ type: types.REQUEST_REPORTS }], [{ type: 'receiveReportsError', payload: expect.any(Error) }], - done, ), axios // wait for initial NO_CONTENT response to be fulfilled @@ -159,35 +153,33 @@ describe('Codequality Reports actions', () => { .then(() => { jest.advanceTimersByTime(pollInterval); }), - ]).catch(done.fail); + ]); }); }); }); describe('receiveReportsSuccess', () => { - it('commits RECEIVE_REPORTS_SUCCESS', (done) => { + it('commits RECEIVE_REPORTS_SUCCESS', () => { const data = { issues: [] }; - testAction( + return testAction( actions.receiveReportsSuccess, data, localState, [{ type: types.RECEIVE_REPORTS_SUCCESS, payload: data }], [], - done, ); }); }); describe('receiveReportsError', () => { - it('commits RECEIVE_REPORTS_ERROR', (done) => { - testAction( + it('commits RECEIVE_REPORTS_ERROR', () => { + return testAction( actions.receiveReportsError, null, localState, [{ type: types.RECEIVE_REPORTS_ERROR, payload: null }], [], - done, ); }); }); diff --git a/spec/frontend/reports/components/report_section_spec.js b/spec/frontend/reports/components/report_section_spec.js index f9eb6dd05f3..888b49f3e0c 100644 --- a/spec/frontend/reports/components/report_section_spec.js +++ b/spec/frontend/reports/components/report_section_spec.js @@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import mountComponent, { mountComponentWithSlots } from 'helpers/vue_mount_component_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; import reportSection from '~/reports/components/report_section.vue'; describe('Report section', () => { @@ -9,6 +10,7 @@ describe('Report section', () => { let wrapper; const ReportSection = Vue.extend(reportSection); const findCollapseButton = () => wrapper.findByTestId('report-section-expand-button'); + const findPopover = () => wrapper.findComponent(HelpPopover); const resolvedIssues = [ { @@ -269,4 +271,33 @@ describe('Report section', () => { expect(vm.$el.textContent.trim()).not.toContain('This is a success'); }); }); + + describe('help popover', () => { + describe('when popover options are defined', () => { + const options = { + title: 'foo', + content: 'bar', + }; + + beforeEach(() => { + createComponent({ + popoverOptions: options, + }); + }); + + it('popover is shown with options', () => { + expect(findPopover().props('options')).toEqual(options); + }); + }); + + describe('when popover options are not defined', () => { + beforeEach(() => { + createComponent({ popoverOptions: {} }); + }); + + it('popover is not shown', () => { + expect(findPopover().exists()).toBe(false); + }); + }); + }); }); diff --git a/spec/frontend/reports/components/summary_row_spec.js b/spec/frontend/reports/components/summary_row_spec.js index 04d9d10dcd2..778660d9e44 100644 --- a/spec/frontend/reports/components/summary_row_spec.js +++ b/spec/frontend/reports/components/summary_row_spec.js @@ -1,25 +1,26 @@ import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; import SummaryRow from '~/reports/components/summary_row.vue'; describe('Summary row', () => { let wrapper; - const props = { - summary: 'SAST detected 1 new vulnerability and 1 fixed vulnerability', - popoverOptions: { - title: 'Static Application Security Testing (SAST)', - content: '<a>Learn more about SAST</a>', - }, - statusIcon: 'warning', + const summary = 'SAST detected 1 new vulnerability and 1 fixed vulnerability'; + const popoverOptions = { + title: 'Static Application Security Testing (SAST)', + content: '<a>Learn more about SAST</a>', }; + const statusIcon = 'warning'; - const createComponent = ({ propsData = {}, slots = {} } = {}) => { + const createComponent = ({ props = {}, slots = {} } = {}) => { wrapper = extendedWrapper( mount(SummaryRow, { propsData: { + summary, + popoverOptions, + statusIcon, ...props, - ...propsData, }, slots, }), @@ -28,6 +29,7 @@ describe('Summary row', () => { const findSummary = () => wrapper.findByTestId('summary-row-description'); const findStatusIcon = () => wrapper.findByTestId('summary-row-icon'); + const findHelpPopover = () => wrapper.findComponent(HelpPopover); afterEach(() => { wrapper.destroy(); @@ -36,7 +38,7 @@ describe('Summary row', () => { it('renders provided summary', () => { createComponent(); - expect(findSummary().text()).toContain(props.summary); + expect(findSummary().text()).toContain(summary); }); it('renders provided icon', () => { @@ -44,12 +46,22 @@ describe('Summary row', () => { expect(findStatusIcon().classes()).toContain('js-ci-status-icon-warning'); }); + it('renders help popover if popoverOptions are provided', () => { + createComponent(); + expect(findHelpPopover().props('options')).toEqual(popoverOptions); + }); + + it('does not render help popover if popoverOptions are not provided', () => { + createComponent({ props: { popoverOptions: null } }); + expect(findHelpPopover().exists()).toBe(false); + }); + describe('summary slot', () => { it('replaces the summary prop', () => { const summarySlotContent = 'Summary slot content'; createComponent({ slots: { summary: summarySlotContent } }); - expect(wrapper.text()).not.toContain(props.summary); + expect(wrapper.text()).not.toContain(summary); expect(findSummary().text()).toContain(summarySlotContent); }); }); diff --git a/spec/frontend/reports/grouped_test_report/store/actions_spec.js b/spec/frontend/reports/grouped_test_report/store/actions_spec.js index bbc3a5dbba5..5876827c548 100644 --- a/spec/frontend/reports/grouped_test_report/store/actions_spec.js +++ b/spec/frontend/reports/grouped_test_report/store/actions_spec.js @@ -24,8 +24,8 @@ describe('Reports Store Actions', () => { }); describe('setPaths', () => { - it('should commit SET_PATHS mutation', (done) => { - testAction( + it('should commit SET_PATHS mutation', () => { + return testAction( setPaths, { endpoint: 'endpoint.json', headBlobPath: '/blob/path' }, mockedState, @@ -36,14 +36,13 @@ describe('Reports Store Actions', () => { }, ], [], - done, ); }); }); describe('requestReports', () => { - it('should commit REQUEST_REPORTS mutation', (done) => { - testAction(requestReports, null, mockedState, [{ type: types.REQUEST_REPORTS }], [], done); + it('should commit REQUEST_REPORTS mutation', () => { + return testAction(requestReports, null, mockedState, [{ type: types.REQUEST_REPORTS }], []); }); }); @@ -62,12 +61,12 @@ describe('Reports Store Actions', () => { }); describe('success', () => { - it('dispatches requestReports and receiveReportsSuccess ', (done) => { + it('dispatches requestReports and receiveReportsSuccess ', () => { mock .onGet(`${TEST_HOST}/endpoint.json`) .replyOnce(200, { summary: {}, suites: [{ name: 'rspec' }] }); - testAction( + return testAction( fetchReports, null, mockedState, @@ -81,7 +80,6 @@ describe('Reports Store Actions', () => { type: 'receiveReportsSuccess', }, ], - done, ); }); }); @@ -91,8 +89,8 @@ describe('Reports Store Actions', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500); }); - it('dispatches requestReports and receiveReportsError ', (done) => { - testAction( + it('dispatches requestReports and receiveReportsError ', () => { + return testAction( fetchReports, null, mockedState, @@ -105,71 +103,65 @@ describe('Reports Store Actions', () => { type: 'receiveReportsError', }, ], - done, ); }); }); }); describe('receiveReportsSuccess', () => { - it('should commit RECEIVE_REPORTS_SUCCESS mutation with 200', (done) => { - testAction( + it('should commit RECEIVE_REPORTS_SUCCESS mutation with 200', () => { + return testAction( receiveReportsSuccess, { data: { summary: {} }, status: 200 }, mockedState, [{ type: types.RECEIVE_REPORTS_SUCCESS, payload: { summary: {} } }], [], - done, ); }); - it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', (done) => { - testAction( + it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', () => { + return testAction( receiveReportsSuccess, { data: { summary: {} }, status: 204 }, mockedState, [], [], - done, ); }); }); describe('receiveReportsError', () => { - it('should commit RECEIVE_REPORTS_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_REPORTS_ERROR mutation', () => { + return testAction( receiveReportsError, null, mockedState, [{ type: types.RECEIVE_REPORTS_ERROR }], [], - done, ); }); }); describe('openModal', () => { - it('should commit SET_ISSUE_MODAL_DATA', (done) => { - testAction( + it('should commit SET_ISSUE_MODAL_DATA', () => { + return testAction( openModal, { name: 'foo' }, mockedState, [{ type: types.SET_ISSUE_MODAL_DATA, payload: { name: 'foo' } }], [], - done, ); }); }); describe('closeModal', () => { - it('should commit RESET_ISSUE_MODAL_DATA', (done) => { - testAction( + it('should commit RESET_ISSUE_MODAL_DATA', () => { + return testAction( closeModal, {}, mockedState, [{ type: types.RESET_ISSUE_MODAL_DATA, payload: {} }], [], - done, ); }); }); 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 7854325e4ed..fea937b905f 100644 --- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap +++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap @@ -106,114 +106,3 @@ exports[`Repository last commit component renders commit widget 1`] = ` </div> </div> `; - -exports[`Repository last commit component renders the signature HTML as returned by the backend 1`] = ` -<div - class="well-segment commit gl-p-5 gl-w-full" -> - <user-avatar-link-stub - class="avatar-cell" - imgalt="" - imgcssclasses="" - imgsize="40" - imgsrc="https://test.com" - linkhref="/test" - tooltipplacement="top" - tooltiptext="" - username="" - /> - - <div - class="commit-detail flex-list" - > - <div - class="commit-content qa-commit-content" - > - <gl-link-stub - class="commit-row-message item-title" - href="/commit/123" - > - Commit title - </gl-link-stub> - - <!----> - - <div - class="committer" - > - <gl-link-stub - class="commit-author-link js-user-link" - href="/test" - > - - Test - </gl-link-stub> - - authored - - <timeago-tooltip-stub - cssclass="" - time="2019-01-01" - tooltipplacement="bottom" - /> - </div> - - <!----> - </div> - - <div - class="commit-actions flex-row" - > - <div> - <button> - Verified - </button> - </div> - - <div - class="ci-status-link" - > - <gl-link-stub - class="js-commit-pipeline" - href="https://test.com/pipeline" - title="Pipeline: failed" - > - <ci-icon-stub - aria-label="Pipeline: failed" - cssclasses="" - size="24" - status="[object Object]" - /> - </gl-link-stub> - </div> - - <gl-button-group-stub - class="gl-ml-4 js-commit-sha-group" - > - <gl-button-stub - buttontextclasses="" - category="primary" - class="gl-font-monospace" - data-testid="last-commit-id-label" - icon="" - label="true" - size="medium" - variant="default" - > - 12345678 - </gl-button-stub> - - <clipboard-button-stub - category="secondary" - class="input-group-text" - size="medium" - text="123456789" - title="Copy commit SHA" - tooltipplacement="top" - variant="default" - /> - </gl-button-group-stub> - </div> - </div> -</div> -`; diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index 96c03419dd6..2f6de03b73d 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -25,6 +25,7 @@ import { redirectTo } from '~/lib/utils/url_utility'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import httpStatusCodes from '~/lib/utils/http_status'; +import LineHighlighter from '~/blob/line_highlighter'; import { simpleViewerMock, richViewerMock, @@ -39,6 +40,7 @@ import { jest.mock('~/repository/components/blob_viewers'); jest.mock('~/lib/utils/url_utility'); jest.mock('~/lib/utils/common_utils'); +jest.mock('~/blob/line_highlighter'); let wrapper; let mockResolver; @@ -173,20 +175,30 @@ describe('Blob content viewer component', () => { }); describe('legacy viewers', () => { + const legacyViewerUrl = 'some_file.js?format=json&viewer=simple'; + const fileType = 'text'; + const highlightJs = false; + it('loads a legacy viewer when a the fileType is text and the highlightJs feature is turned off', async () => { await createComponent({ - blob: { ...simpleViewerMock, fileType: 'text', highlightJs: false }, + blob: { ...simpleViewerMock, fileType, highlightJs }, }); expect(mockAxios.history.get).toHaveLength(1); - expect(mockAxios.history.get[0].url).toEqual('some_file.js?format=json&viewer=simple'); + 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' } }); expect(mockAxios.history.get).toHaveLength(1); - expect(mockAxios.history.get[0].url).toEqual('some_file.js?format=json&viewer=simple'); + expect(mockAxios.history.get[0].url).toBe(legacyViewerUrl); + }); + + it('loads the LineHighlighter', async () => { + mockAxios.onGet(legacyViewerUrl).replyOnce(httpStatusCodes.OK, 'test'); + await createComponent({ blob: { ...simpleViewerMock, fileType, highlightJs } }); + expect(LineHighlighter).toHaveBeenCalled(); }); }); }); @@ -258,6 +270,7 @@ describe('Blob content viewer component', () => { codeNavigationPath: simpleViewerMock.codeNavigationPath, blobPath: simpleViewerMock.path, pathPrefix: simpleViewerMock.projectBlobPathRoot, + wrapTextNodes: true, }); }); diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js index 0e3e7075e99..eef66045573 100644 --- a/spec/frontend/repository/components/breadcrumbs_spec.js +++ b/spec/frontend/repository/components/breadcrumbs_spec.js @@ -12,7 +12,7 @@ const defaultMockRoute = { describe('Repository breadcrumbs component', () => { let wrapper; - const factory = (currentPath, extraProps = {}, mockRoute = {}, newDirModal = true) => { + const factory = (currentPath, extraProps = {}, mockRoute = {}) => { const $apollo = { queries: { userPermissions: { @@ -36,7 +36,6 @@ describe('Repository breadcrumbs component', () => { }, $apollo, }, - provide: { glFeatures: { newDirModal } }, }); }; @@ -147,37 +146,21 @@ describe('Repository breadcrumbs component', () => { }); describe('renders the new directory modal', () => { - describe('with the feature flag enabled', () => { - beforeEach(() => { - window.gon.features = { - newDirModal: true, - }; - factory('/', { canEditTree: true }); - }); - - it('does not render the modal while loading', () => { - expect(findNewDirectoryModal().exists()).toBe(false); - }); - - it('renders the modal once loaded', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ $apollo: { queries: { userPermissions: { loading: false } } } }); - - await nextTick(); - - expect(findNewDirectoryModal().exists()).toBe(true); - }); + beforeEach(() => { + factory('/', { canEditTree: true }); + }); + it('does not render the modal while loading', () => { + expect(findNewDirectoryModal().exists()).toBe(false); }); - describe('with the feature flag disabled', () => { - it('does not render the modal', () => { - window.gon.features = { - newDirModal: false, - }; - factory('/', { canEditTree: true }, {}, {}, false); - expect(findNewDirectoryModal().exists()).toBe(false); - }); + it('renders the modal once loaded', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ $apollo: { queries: { userPermissions: { loading: false } } } }); + + await nextTick(); + + expect(findNewDirectoryModal().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js index bb710c3a96c..cfbf74e34aa 100644 --- a/spec/frontend/repository/components/last_commit_spec.js +++ b/spec/frontend/repository/components/last_commit_spec.js @@ -143,11 +143,30 @@ describe('Repository last commit component', () => { }); it('renders the signature HTML as returned by the backend', async () => { - factory(createCommitData({ signatureHtml: '<button>Verified</button>' })); + 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.element).toMatchSnapshot(); + 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>`, + ); }); it('sets correct CSS class if the commit message is empty', async () => { 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 cdaec0a3a8b..2ef856c90ab 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -13,6 +13,7 @@ import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory } from '~/lib/utils/url_utility'; +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'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; @@ -30,9 +31,10 @@ import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE, + PARAM_KEY_PAUSED, PARAM_KEY_STATUS, PARAM_KEY_TAG, - STATUS_ACTIVE, + STATUS_ONLINE, RUNNER_PAGE_SIZE, } from '~/runner/constants'; import adminRunnersQuery from '~/runner/graphql/list/admin_runners.query.graphql'; @@ -40,9 +42,16 @@ import adminRunnersCountQuery from '~/runner/graphql/list/admin_runners_count.qu import { captureException } from '~/runner/sentry_utils'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import { runnersData, runnersCountData, runnersDataPaginated } from '../mock_data'; +import { + runnersData, + runnersCountData, + runnersDataPaginated, + onlineContactTimeoutSecs, + staleTimeoutSecs, +} from '../mock_data'; const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; +const mockRunners = runnersData.data.runners.nodes; jest.mock('~/flash'); jest.mock('~/runner/sentry_utils'); @@ -58,6 +67,8 @@ describe('AdminRunnersApp', () => { let wrapper; let mockRunnersQuery; let mockRunnersCountQuery; + let cacheConfig; + let localMutations; const findRunnerStats = () => wrapper.findComponent(RunnerStats); const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell); @@ -69,18 +80,32 @@ describe('AdminRunnersApp', () => { const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); - const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => { + const createComponent = ({ + props = {}, + mountFn = shallowMountExtended, + provide, + ...options + } = {}) => { + ({ cacheConfig, localMutations } = createLocalState()); + const handlers = [ [adminRunnersQuery, mockRunnersQuery], [adminRunnersCountQuery, mockRunnersCountQuery], ]; wrapper = mountFn(AdminRunnersApp, { - apolloProvider: createMockApollo(handlers), + apolloProvider: createMockApollo(handlers, {}, cacheConfig), propsData: { registrationToken: mockRegistrationToken, ...props, }, + provide: { + localMutations, + onlineContactTimeoutSecs, + staleTimeoutSecs, + ...provide, + }, + ...options, }); }; @@ -173,7 +198,7 @@ describe('AdminRunnersApp', () => { }); it('shows the runners list', () => { - expect(findRunnerList().props('runners')).toEqual(runnersData.data.runners.nodes); + expect(findRunnerList().props('runners')).toEqual(mockRunners); }); it('runner item links to the runner admin page', async () => { @@ -181,7 +206,7 @@ describe('AdminRunnersApp', () => { await waitForPromises(); - const { id, shortSha } = runnersData.data.runners.nodes[0]; + const { id, shortSha } = mockRunners[0]; const numericId = getIdFromGraphQLId(id); const runnerLink = wrapper.find('tr [data-testid="td-summary"]').find(GlLink); @@ -197,7 +222,7 @@ describe('AdminRunnersApp', () => { const runnerActions = wrapper.find('tr [data-testid="td-actions"]').find(RunnerActionsCell); - const runner = runnersData.data.runners.nodes[0]; + const runner = mockRunners[0]; expect(runnerActions.props()).toEqual({ runner, @@ -219,6 +244,10 @@ describe('AdminRunnersApp', () => { expect(findFilteredSearch().props('tokens')).toEqual([ expect.objectContaining({ + type: PARAM_KEY_PAUSED, + options: expect.any(Array), + }), + expect.objectContaining({ type: PARAM_KEY_STATUS, options: expect.any(Array), }), @@ -232,12 +261,13 @@ describe('AdminRunnersApp', () => { describe('Single runner row', () => { let showToast; - const mockRunner = runnersData.data.runners.nodes[0]; - const { id: graphqlId, shortSha } = mockRunner; + const { id: graphqlId, shortSha } = mockRunners[0]; const id = getIdFromGraphQLId(graphqlId); + const COUNT_QUERIES = 7; // Smart queries that display a filtered count of runners + const FILTERED_COUNT_QUERIES = 4; // Smart queries that display a count of runners in tabs beforeEach(async () => { - mockRunnersQuery.mockClear(); + mockRunnersCountQuery.mockClear(); createComponent({ mountFn: mountExtended }); showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show'); @@ -252,12 +282,18 @@ describe('AdminRunnersApp', () => { expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${id}`); }); - it('When runner is deleted, data is refetched and a toast message is shown', async () => { - expect(mockRunnersQuery).toHaveBeenCalledTimes(1); + it('When runner is paused or unpaused, some data is refetched', async () => { + expect(mockRunnersCountQuery).toHaveBeenCalledTimes(COUNT_QUERIES); - findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' }); + findRunnerActionsCell().vm.$emit('toggledPaused'); - expect(mockRunnersQuery).toHaveBeenCalledTimes(2); + expect(mockRunnersCountQuery).toHaveBeenCalledTimes(COUNT_QUERIES + FILTERED_COUNT_QUERIES); + + expect(showToast).toHaveBeenCalledTimes(0); + }); + + it('When runner is deleted, data is refetched and a toast message is shown', async () => { + findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' }); expect(showToast).toHaveBeenCalledTimes(1); expect(showToast).toHaveBeenCalledWith('Runner deleted'); @@ -266,7 +302,7 @@ describe('AdminRunnersApp', () => { describe('when a filter is preselected', () => { beforeEach(async () => { - setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`); + setWindowLocation(`?status[]=${STATUS_ONLINE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`); createComponent(); await waitForPromises(); @@ -276,7 +312,7 @@ describe('AdminRunnersApp', () => { expect(findRunnerFilteredSearchBar().props('value')).toEqual({ runnerType: INSTANCE_TYPE, filters: [ - { type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }, + { type: 'status', value: { data: STATUS_ONLINE, operator: '=' } }, { type: 'tag', value: { data: 'tag1', operator: '=' } }, ], sort: 'CREATED_DESC', @@ -286,7 +322,7 @@ describe('AdminRunnersApp', () => { it('requests the runners with filter parameters', () => { expect(mockRunnersQuery).toHaveBeenLastCalledWith({ - status: STATUS_ACTIVE, + status: STATUS_ONLINE, type: INSTANCE_TYPE, tagList: ['tag1'], sort: DEFAULT_SORT, @@ -299,7 +335,7 @@ describe('AdminRunnersApp', () => { beforeEach(() => { findRunnerFilteredSearchBar().vm.$emit('input', { runnerType: null, - filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }], + filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], sort: CREATED_ASC, }); }); @@ -307,13 +343,13 @@ describe('AdminRunnersApp', () => { it('updates the browser url', () => { expect(updateHistory).toHaveBeenLastCalledWith({ title: expect.any(String), - url: 'http://test.host/admin/runners?status[]=ACTIVE&sort=CREATED_ASC', + url: 'http://test.host/admin/runners?status[]=ONLINE&sort=CREATED_ASC', }); }); it('requests the runners with filters', () => { expect(mockRunnersQuery).toHaveBeenLastCalledWith({ - status: STATUS_ACTIVE, + status: STATUS_ONLINE, sort: CREATED_ASC, first: RUNNER_PAGE_SIZE, }); @@ -325,6 +361,41 @@ describe('AdminRunnersApp', () => { expect(findRunnerList().props('loading')).toBe(true); }); + describe('when bulk delete is enabled', () => { + beforeEach(() => { + createComponent({ + provide: { + glFeatures: { adminRunnersBulkDelete: true }, + }, + }); + }); + + it('runner list is checkable', () => { + expect(findRunnerList().props('checkable')).toBe(true); + }); + + it('responds to checked items by updating the local cache', () => { + const setRunnerCheckedMock = jest + .spyOn(localMutations, 'setRunnerChecked') + .mockImplementation(() => {}); + + const runner = mockRunners[0]; + + expect(setRunnerCheckedMock).toHaveBeenCalledTimes(0); + + findRunnerList().vm.$emit('checked', { + runner, + isChecked: true, + }); + + expect(setRunnerCheckedMock).toHaveBeenCalledTimes(1); + expect(setRunnerCheckedMock).toHaveBeenCalledWith({ + runner, + isChecked: true, + }); + }); + }); + describe('when no runners are found', () => { beforeEach(async () => { mockRunnersQuery = jest.fn().mockResolvedValue({ diff --git a/spec/frontend/runner/components/__snapshots__/runner_status_popover_spec.js.snap b/spec/frontend/runner/components/__snapshots__/runner_status_popover_spec.js.snap new file mode 100644 index 00000000000..80a04401760 --- /dev/null +++ b/spec/frontend/runner/components/__snapshots__/runner_status_popover_spec.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RunnerStatusPopover renders complete text 1`] = `"Never contacted: Runner has never contacted GitLab (when you register a runner, use gitlab-runner run to bring it online) Online: Runner has contacted GitLab within the last 2 hours Offline: Runner has not contacted GitLab in more than 2 hours Stale: Runner has not contacted GitLab in more than 2 months"`; 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 0d579106860..7a949cb6505 100644 --- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js @@ -92,6 +92,24 @@ describe('RunnerActionsCell', () => { expect(findDeleteBtn().props('compact')).toBe(true); }); + it('Passes runner data to delete button', () => { + createComponent({ + runner: mockRunner, + }); + + expect(findDeleteBtn().props('runner')).toEqual(mockRunner); + }); + + it('Emits toggledPaused events', () => { + createComponent(); + + expect(wrapper.emitted('toggledPaused')).toBe(undefined); + + findRunnerPauseBtn().vm.$emit('toggledPaused'); + + expect(wrapper.emitted('toggledPaused')).toHaveLength(1); + }); + it('Emits delete events', () => { const value = { name: 'Runner' }; @@ -104,7 +122,7 @@ describe('RunnerActionsCell', () => { expect(wrapper.emitted('deleted')).toEqual([[value]]); }); - it('Does not render the runner delete button when user cannot delete', () => { + it('Renders the runner delete disabled button when user cannot delete', () => { createComponent({ runner: { userPermissions: { @@ -114,7 +132,7 @@ describe('RunnerActionsCell', () => { }, }); - expect(findDeleteBtn().exists()).toBe(false); + expect(findDeleteBtn().props('disabled')).toBe(true); }); }); }); diff --git a/spec/frontend/runner/components/cells/runner_summary_cell_spec.js b/spec/frontend/runner/components/cells/runner_summary_cell_spec.js index b6d957d27ea..b2e8c5a3ad9 100644 --- a/spec/frontend/runner/components/cells/runner_summary_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_summary_cell_spec.js @@ -5,6 +5,7 @@ import { INSTANCE_TYPE, PROJECT_TYPE } from '~/runner/constants'; const mockId = '1'; const mockShortSha = '2P6oDVDm'; const mockDescription = 'runner-1'; +const mockIpAddress = '0.0.0.0'; describe('RunnerTypeCell', () => { let wrapper; @@ -18,6 +19,7 @@ describe('RunnerTypeCell', () => { id: `gid://gitlab/Ci::Runner/${mockId}`, shortSha: mockShortSha, description: mockDescription, + ipAddress: mockIpAddress, runnerType: INSTANCE_TYPE, ...runner, }, @@ -59,6 +61,10 @@ describe('RunnerTypeCell', () => { expect(wrapper.text()).toContain(mockDescription); }); + it('Displays the runner ip address', () => { + expect(wrapper.text()).toContain(mockIpAddress); + }); + it('Displays a custom slot', () => { const slotContent = 'My custom runner summary'; diff --git a/spec/frontend/runner/components/registration/registration_dropdown_spec.js b/spec/frontend/runner/components/registration/registration_dropdown_spec.js index da8ef7c3af0..5cd93df9967 100644 --- a/spec/frontend/runner/components/registration/registration_dropdown_spec.js +++ b/spec/frontend/runner/components/registration/registration_dropdown_spec.js @@ -8,6 +8,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; +import RegistrationToken from '~/runner/components/registration/registration_token.vue'; import RegistrationTokenResetDropdownItem from '~/runner/components/registration/registration_token_reset_dropdown_item.vue'; import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; @@ -30,11 +31,11 @@ describe('RegistrationDropdown', () => { const findRegistrationInstructionsDropdownItem = () => wrapper.findComponent(GlDropdownItem); const findTokenDropdownItem = () => wrapper.findComponent(GlDropdownForm); + const findRegistrationToken = () => wrapper.findComponent(RegistrationToken); + const findRegistrationTokenInput = () => wrapper.findByTestId('token-value').find('input'); const findTokenResetDropdownItem = () => wrapper.findComponent(RegistrationTokenResetDropdownItem); - const findToggleMaskButton = () => wrapper.findByTestId('toggle-masked'); - const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMount) => { wrapper = extendedWrapper( mountFn(RegistrationDropdown, { @@ -134,9 +135,7 @@ describe('RegistrationDropdown', () => { it('Displays masked value by default', () => { createComponent({}, mount); - expect(findTokenDropdownItem().text()).toMatchInterpolatedText( - `Registration token ${maskToken}`, - ); + expect(findRegistrationTokenInput().element.value).toBe(maskToken); }); }); @@ -155,16 +154,14 @@ describe('RegistrationDropdown', () => { }); it('Updates the token when it gets reset', async () => { + const newToken = 'mock1'; createComponent({}, mount); - const newToken = 'mock1'; + expect(findRegistrationTokenInput().props('value')).not.toBe(newToken); findTokenResetDropdownItem().vm.$emit('tokenReset', newToken); - findToggleMaskButton().vm.$emit('click', { stopPropagation: jest.fn() }); await nextTick(); - expect(findTokenDropdownItem().text()).toMatchInterpolatedText( - `Registration token ${newToken}`, - ); + expect(findRegistrationToken().props('value')).toBe(newToken); }); }); diff --git a/spec/frontend/runner/components/registration/registration_token_spec.js b/spec/frontend/runner/components/registration/registration_token_spec.js index 6b9708cc525..cb42c7c8493 100644 --- a/spec/frontend/runner/components/registration/registration_token_spec.js +++ b/spec/frontend/runner/components/registration/registration_token_spec.js @@ -1,20 +1,17 @@ -import { nextTick } from 'vue'; import { GlToast } from '@gitlab/ui'; import { createLocalVue } from '@vue/test-utils'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import RegistrationToken from '~/runner/components/registration/registration_token.vue'; -import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; +import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; const mockToken = '01234567890'; const mockMasked = '***********'; describe('RegistrationToken', () => { let wrapper; - let stopPropagation; let showToast; - const findToggleMaskButton = () => wrapper.findByTestId('toggle-masked'); - const findCopyButton = () => wrapper.findComponent(ModalCopyButton); + const findInputCopyToggleVisibility = () => wrapper.findComponent(InputCopyToggleVisibility); const vueWithGlToast = () => { const localVue = createLocalVue(); @@ -22,10 +19,14 @@ describe('RegistrationToken', () => { return localVue; }; - const createComponent = ({ props = {}, withGlToast = true } = {}) => { + const createComponent = ({ + props = {}, + withGlToast = true, + mountFn = shallowMountExtended, + } = {}) => { const localVue = withGlToast ? vueWithGlToast() : undefined; - wrapper = shallowMountExtended(RegistrationToken, { + wrapper = mountFn(RegistrationToken, { propsData: { value: mockToken, ...props, @@ -36,61 +37,33 @@ describe('RegistrationToken', () => { showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null; }; - beforeEach(() => { - stopPropagation = jest.fn(); - - createComponent(); - }); - afterEach(() => { wrapper.destroy(); }); - it('Displays masked value by default', () => { - expect(wrapper.text()).toBe(mockMasked); - }); + it('Displays value and copy button', () => { + createComponent(); - it('Displays button to reveal token', () => { - expect(findToggleMaskButton().attributes('aria-label')).toBe('Click to reveal'); + expect(findInputCopyToggleVisibility().props('value')).toBe(mockToken); + expect(findInputCopyToggleVisibility().props('copyButtonTitle')).toBe( + 'Copy registration token', + ); }); - it('Can copy the original token value', () => { - expect(findCopyButton().props('text')).toBe(mockToken); + // Component integration test to ensure secure masking + it('Displays masked value by default', () => { + createComponent({ mountFn: mountExtended }); + + expect(wrapper.find('input').element.value).toBe(mockMasked); }); - describe('When the reveal icon is clicked', () => { + describe('When the copy to clipboard button is clicked', () => { beforeEach(() => { - findToggleMaskButton().vm.$emit('click', { stopPropagation }); - }); - - it('Click event is not propagated', async () => { - expect(stopPropagation).toHaveBeenCalledTimes(1); + createComponent(); }); - it('Displays the actual value', () => { - expect(wrapper.text()).toBe(mockToken); - }); - - it('Can copy the original token value', () => { - expect(findCopyButton().props('text')).toBe(mockToken); - }); - - it('Displays button to mask token', () => { - expect(findToggleMaskButton().attributes('aria-label')).toBe('Click to hide'); - }); - - it('When user clicks again, displays masked value', async () => { - findToggleMaskButton().vm.$emit('click', { stopPropagation }); - await nextTick(); - - expect(wrapper.text()).toBe(mockMasked); - expect(findToggleMaskButton().attributes('aria-label')).toBe('Click to reveal'); - }); - }); - - describe('When the copy to clipboard button is clicked', () => { it('shows a copied message', () => { - findCopyButton().vm.$emit('success'); + findInputCopyToggleVisibility().vm.$emit('copy'); expect(showToast).toHaveBeenCalledTimes(1); expect(showToast).toHaveBeenCalledWith('Registration token copied!'); @@ -98,7 +71,7 @@ describe('RegistrationToken', () => { it('does not fail when toast is not defined', () => { createComponent({ withGlToast: false }); - findCopyButton().vm.$emit('success'); + findInputCopyToggleVisibility().vm.$emit('copy'); // This block also tests for unhandled errors expect(showToast).toBeNull(); diff --git a/spec/frontend/runner/components/runner_assigned_item_spec.js b/spec/frontend/runner/components/runner_assigned_item_spec.js index c6156c16d4a..1ff6983fbe7 100644 --- a/spec/frontend/runner/components/runner_assigned_item_spec.js +++ b/spec/frontend/runner/components/runner_assigned_item_spec.js @@ -1,6 +1,7 @@ import { GlAvatar } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import RunnerAssignedItem from '~/runner/components/runner_assigned_item.vue'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; const mockHref = '/group/project'; const mockName = 'Project'; @@ -40,7 +41,7 @@ describe('RunnerAssignedItem', () => { alt: mockName, entityName: mockName, src: mockAvatarUrl, - shape: 'rect', + shape: AVATAR_SHAPE_OPTION_RECT, size: 48, }); }); diff --git a/spec/frontend/runner/components/runner_bulk_delete_spec.js b/spec/frontend/runner/components/runner_bulk_delete_spec.js new file mode 100644 index 00000000000..f5b56396cf1 --- /dev/null +++ b/spec/frontend/runner/components/runner_bulk_delete_spec.js @@ -0,0 +1,103 @@ +import Vue from 'vue'; +import { GlSprintf } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import RunnerBulkDelete from '~/runner/components/runner_bulk_delete.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { createLocalState } from '~/runner/graphql/list/local_state'; +import waitForPromises from 'helpers/wait_for_promises'; + +Vue.use(VueApollo); + +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); + +describe('RunnerBulkDelete', () => { + let wrapper; + let mockState; + let mockCheckedRunnerIds; + + const findClearBtn = () => wrapper.findByTestId('clear-btn'); + const findDeleteBtn = () => wrapper.findByTestId('delete-btn'); + + const createComponent = () => { + const { cacheConfig, localMutations } = mockState; + + wrapper = shallowMountExtended(RunnerBulkDelete, { + apolloProvider: createMockApollo(undefined, undefined, cacheConfig), + provide: { + localMutations, + }, + stubs: { + GlSprintf, + }, + }); + }; + + beforeEach(() => { + mockState = createLocalState(); + + jest + .spyOn(mockState.cacheConfig.typePolicies.Query.fields, 'checkedRunnerIds') + .mockImplementation(() => mockCheckedRunnerIds); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('When no runners are checked', () => { + beforeEach(async () => { + mockCheckedRunnerIds = []; + + createComponent(); + + await waitForPromises(); + }); + + it('shows no contents', () => { + expect(wrapper.html()).toBe(''); + }); + }); + + describe.each` + count | ids | text + ${1} | ${['gid:Runner/1']} | ${'1 runner'} + ${2} | ${['gid:Runner/1', 'gid:Runner/2']} | ${'2 runners'} + `('When $count runner(s) are checked', ({ count, ids, text }) => { + beforeEach(() => { + mockCheckedRunnerIds = ids; + + createComponent(); + + jest.spyOn(mockState.localMutations, 'clearChecked').mockImplementation(() => {}); + }); + + it(`shows "${text}"`, () => { + expect(wrapper.text()).toContain(text); + }); + + it('clears selection', () => { + expect(mockState.localMutations.clearChecked).toHaveBeenCalledTimes(0); + + findClearBtn().vm.$emit('click'); + + expect(mockState.localMutations.clearChecked).toHaveBeenCalledTimes(1); + }); + + it('shows confirmation modal', () => { + expect(confirmAction).toHaveBeenCalledTimes(0); + + findDeleteBtn().vm.$emit('click'); + + expect(confirmAction).toHaveBeenCalledTimes(1); + + const [, confirmOptions] = confirmAction.mock.calls[0]; + const { title, modalHtmlMessage, primaryBtnText } = confirmOptions; + + expect(title).toMatch(text); + expect(primaryBtnText).toMatch(text); + expect(modalHtmlMessage).toMatch(`<strong>${count}</strong>`); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_delete_button_spec.js b/spec/frontend/runner/components/runner_delete_button_spec.js index 81c870f23cf..3eb257607b4 100644 --- a/spec/frontend/runner/components/runner_delete_button_spec.js +++ b/spec/frontend/runner/components/runner_delete_button_spec.js @@ -9,7 +9,11 @@ import waitForPromises from 'helpers/wait_for_promises'; import { captureException } from '~/runner/sentry_utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { createAlert } from '~/flash'; -import { I18N_DELETE_RUNNER } from '~/runner/constants'; +import { + I18N_DELETE_RUNNER, + I18N_DELETE_DISABLED_MANY_PROJECTS, + I18N_DELETE_DISABLED_UNKNOWN_REASON, +} from '~/runner/constants'; import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue'; import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue'; @@ -25,26 +29,32 @@ jest.mock('~/runner/sentry_utils'); describe('RunnerDeleteButton', () => { let wrapper; + let apolloProvider; + let apolloCache; let runnerDeleteHandler; - const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value; - const getModal = () => getBinding(wrapper.element, 'gl-modal').value; const findBtn = () => wrapper.findComponent(GlButton); const findModal = () => wrapper.findComponent(RunnerDeleteModal); + const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value; + const getModal = () => getBinding(findBtn().element, 'gl-modal').value; + const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => { const { runner, ...propsData } = props; wrapper = mountFn(RunnerDeleteButton, { propsData: { runner: { + // We need typename so that cache.identify works + // eslint-disable-next-line no-underscore-dangle + __typename: mockRunner.__typename, id: mockRunner.id, shortSha: mockRunner.shortSha, ...runner, }, ...propsData, }, - apolloProvider: createMockApollo([[runnerDeleteMutation, runnerDeleteHandler]]), + apolloProvider, directives: { GlTooltip: createMockDirective(), GlModal: createMockDirective(), @@ -67,6 +77,11 @@ describe('RunnerDeleteButton', () => { }, }); }); + apolloProvider = createMockApollo([[runnerDeleteMutation, runnerDeleteHandler]]); + apolloCache = apolloProvider.defaultClient.cache; + + jest.spyOn(apolloCache, 'evict'); + jest.spyOn(apolloCache, 'gc'); createComponent(); }); @@ -88,6 +103,10 @@ describe('RunnerDeleteButton', () => { expect(findModal().props('runnerName')).toBe(`#${mockRunnerId} (${mockRunner.shortSha})`); }); + it('Does not have tabindex when button is enabled', () => { + expect(wrapper.attributes('tabindex')).toBeUndefined(); + }); + it('Displays a modal when clicked', () => { const modalId = `delete-runner-modal-${mockRunnerId}`; @@ -140,6 +159,13 @@ describe('RunnerDeleteButton', () => { expect(deleted[0][0].message).toMatch(`#${mockRunnerId}`); expect(deleted[0][0].message).toMatch(`${mockRunner.shortSha}`); }); + + it('evicts runner from apollo cache', () => { + expect(apolloCache.evict).toHaveBeenCalledWith({ + id: apolloCache.identify(mockRunner), + }); + expect(apolloCache.gc).toHaveBeenCalled(); + }); }); describe('When update fails', () => { @@ -190,6 +216,11 @@ describe('RunnerDeleteButton', () => { it('error is shown to the user', () => { expect(createAlert).toHaveBeenCalledTimes(1); }); + + it('does not evict runner from apollo cache', () => { + expect(apolloCache.evict).not.toHaveBeenCalled(); + expect(apolloCache.gc).not.toHaveBeenCalled(); + }); }); }); @@ -230,4 +261,29 @@ describe('RunnerDeleteButton', () => { }); }); }); + + describe.each` + reason | runner | tooltip + ${'runner belongs to more than 1 project'} | ${{ projectCount: 2 }} | ${I18N_DELETE_DISABLED_MANY_PROJECTS} + ${'unknown reason'} | ${{}} | ${I18N_DELETE_DISABLED_UNKNOWN_REASON} + `('When button is disabled because $reason', ({ runner, tooltip }) => { + beforeEach(() => { + createComponent({ + props: { + disabled: true, + runner, + }, + }); + }); + + it('Displays a disabled delete button', () => { + expect(findBtn().props('disabled')).toBe(true); + }); + + it(`Tooltip "${tooltip}" is shown`, () => { + // tabindex is required for a11y + expect(wrapper.attributes('tabindex')).toBe('0'); + expect(getTooltip()).toBe(tooltip); + }); + }); }); 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 fda96e5918e..b1b436e5443 100644 --- a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js +++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js @@ -4,7 +4,7 @@ import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_ import { statusTokenConfig } from '~/runner/components/search_tokens/status_token_config'; import TagToken from '~/runner/components/search_tokens/tag_token.vue'; import { tagTokenConfig } from '~/runner/components/search_tokens/tag_token_config'; -import { PARAM_KEY_STATUS, PARAM_KEY_TAG, STATUS_ACTIVE, INSTANCE_TYPE } from '~/runner/constants'; +import { PARAM_KEY_STATUS, PARAM_KEY_TAG, STATUS_ONLINE, INSTANCE_TYPE } from '~/runner/constants'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -18,7 +18,7 @@ describe('RunnerList', () => { const mockDefaultSort = 'CREATED_DESC'; const mockOtherSort = 'CONTACTED_DESC'; const mockFilters = [ - { type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }, + { type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }, { type: 'filtered-search-term', value: { data: '' } }, ]; @@ -113,7 +113,7 @@ describe('RunnerList', () => { }); it('filter values are shown', () => { - expect(findGlFilteredSearch().props('value')).toEqual(mockFilters); + expect(findGlFilteredSearch().props('value')).toMatchObject(mockFilters); }); it('sort option is selected', () => { diff --git a/spec/frontend/runner/components/runner_jobs_spec.js b/spec/frontend/runner/components/runner_jobs_spec.js index 9abb2861005..9e40e911448 100644 --- a/spec/frontend/runner/components/runner_jobs_spec.js +++ b/spec/frontend/runner/components/runner_jobs_spec.js @@ -1,4 +1,4 @@ -import { GlSkeletonLoading } from '@gitlab/ui'; +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js index a0f42738d2c..872394430ae 100644 --- a/spec/frontend/runner/components/runner_list_spec.js +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -6,7 +6,8 @@ import { } from 'helpers/vue_test_utils_helper'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerList from '~/runner/components/runner_list.vue'; -import { runnersData } from '../mock_data'; +import RunnerStatusPopover from '~/runner/components/runner_status_popover.vue'; +import { runnersData, onlineContactTimeoutSecs, staleTimeoutSecs } from '../mock_data'; const mockRunners = runnersData.data.runners.nodes; const mockActiveRunnersCount = mockRunners.length; @@ -28,26 +29,38 @@ describe('RunnerList', () => { activeRunnersCount: mockActiveRunnersCount, ...props, }, + provide: { + onlineContactTimeoutSecs, + staleTimeoutSecs, + }, ...options, }); }; - beforeEach(() => { - createComponent({}, mountExtended); - }); - afterEach(() => { wrapper.destroy(); }); it('Displays headers', () => { + createComponent( + { + stubs: { + RunnerStatusPopover: { + template: '<div/>', + }, + }, + }, + mountExtended, + ); + const headerLabels = findHeaders().wrappers.map((w) => w.text()); + expect(findHeaders().at(0).findComponent(RunnerStatusPopover).exists()).toBe(true); + expect(headerLabels).toEqual([ 'Status', 'Runner', 'Version', - 'IP', 'Jobs', 'Tags', 'Last contact', @@ -56,19 +69,23 @@ describe('RunnerList', () => { }); it('Sets runner id as a row key', () => { - createComponent({}); + createComponent(); expect(findTable().attributes('primary-key')).toBe('id'); }); it('Displays a list of runners', () => { + createComponent({}, mountExtended); + expect(findRows()).toHaveLength(4); expect(findSkeletonLoader().exists()).toBe(false); }); it('Displays details of a runner', () => { - const { id, description, version, ipAddress, shortSha } = mockRunners[0]; + const { id, description, version, shortSha } = mockRunners[0]; + + createComponent({}, mountExtended); // Badges expect(findCell({ fieldKey: 'status' }).text()).toMatchInterpolatedText( @@ -83,7 +100,6 @@ describe('RunnerList', () => { // Other fields expect(findCell({ fieldKey: 'version' }).text()).toBe(version); - expect(findCell({ fieldKey: 'ipAddress' }).text()).toBe(ipAddress); expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('0'); expect(findCell({ fieldKey: 'tagList' }).text()).toBe(''); expect(findCell({ fieldKey: 'contactedAt' }).text()).toEqual(expect.any(String)); @@ -92,6 +108,35 @@ describe('RunnerList', () => { expect(findCell({ fieldKey: 'actions' }).exists()).toBe(true); }); + describe('When the list is checkable', () => { + beforeEach(() => { + createComponent( + { + props: { + checkable: true, + }, + }, + mountExtended, + ); + }); + + it('Displays a checkbox field', () => { + expect(findCell({ fieldKey: 'checkbox' }).find('input').exists()).toBe(true); + }); + + it('Emits a checked event', () => { + const checkbox = findCell({ fieldKey: 'checkbox' }).find('input'); + + checkbox.setChecked(); + + expect(wrapper.emitted('checked')).toHaveLength(1); + expect(wrapper.emitted('checked')[0][0]).toEqual({ + isChecked: true, + runner: mockRunners[0], + }); + }); + }); + describe('Scoped cell slots', () => { it('Render #runner-name slot in "summary" cell', () => { createComponent( @@ -156,6 +201,8 @@ describe('RunnerList', () => { const { id, shortSha } = mockRunners[0]; const numericId = getIdFromGraphQLId(id); + createComponent({}, mountExtended); + expect(findCell({ fieldKey: 'summary' }).text()).toContain(`#${numericId} (${shortSha})`); }); diff --git a/spec/frontend/runner/components/runner_pause_button_spec.js b/spec/frontend/runner/components/runner_pause_button_spec.js index 3d9df03977e..9ebb30b6ed7 100644 --- a/spec/frontend/runner/components/runner_pause_button_spec.js +++ b/spec/frontend/runner/components/runner_pause_button_spec.js @@ -146,6 +146,10 @@ describe('RunnerPauseButton', () => { it('The button does not have a loading state', () => { expect(findBtn().props('loading')).toBe(false); }); + + it('The button emits toggledPaused', () => { + expect(wrapper.emitted('toggledPaused')).toHaveLength(1); + }); }); describe('When update fails', () => { diff --git a/spec/frontend/runner/components/runner_projects_spec.js b/spec/frontend/runner/components/runner_projects_spec.js index 96de8d11bca..62ebc6539e2 100644 --- a/spec/frontend/runner/components/runner_projects_spec.js +++ b/spec/frontend/runner/components/runner_projects_spec.js @@ -1,4 +1,4 @@ -import { GlSkeletonLoading } from '@gitlab/ui'; +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; diff --git a/spec/frontend/runner/components/runner_status_badge_spec.js b/spec/frontend/runner/components/runner_status_badge_spec.js index c470c6bb989..bb833bd7d5a 100644 --- a/spec/frontend/runner/components/runner_status_badge_spec.js +++ b/spec/frontend/runner/components/runner_status_badge_spec.js @@ -7,6 +7,8 @@ import { STATUS_OFFLINE, STATUS_STALE, STATUS_NEVER_CONTACTED, + I18N_NEVER_CONTACTED_TOOLTIP, + I18N_STALE_NEVER_CONTACTED_TOOLTIP, } from '~/runner/constants'; describe('RunnerTypeBadge', () => { @@ -59,7 +61,7 @@ describe('RunnerTypeBadge', () => { expect(wrapper.text()).toBe('never contacted'); expect(findBadge().props('variant')).toBe('muted'); - expect(getTooltip().value).toMatch('This runner has never contacted'); + expect(getTooltip().value).toBe(I18N_NEVER_CONTACTED_TOOLTIP); }); it('renders offline state', () => { @@ -72,9 +74,7 @@ describe('RunnerTypeBadge', () => { expect(wrapper.text()).toBe('offline'); expect(findBadge().props('variant')).toBe('muted'); - expect(getTooltip().value).toBe( - 'No recent contact from this runner; last contact was 1 day ago', - ); + expect(getTooltip().value).toBe('Runner is offline; last contact was 1 day ago'); }); it('renders stale state', () => { @@ -87,7 +87,20 @@ describe('RunnerTypeBadge', () => { expect(wrapper.text()).toBe('stale'); expect(findBadge().props('variant')).toBe('warning'); - expect(getTooltip().value).toBe('No contact from this runner in over 3 months'); + expect(getTooltip().value).toBe('Runner is stale; last contact was 1 year ago'); + }); + + it('renders stale state with no contact time', () => { + createComponent({ + runner: { + contactedAt: null, + status: STATUS_STALE, + }, + }); + + expect(wrapper.text()).toBe('stale'); + expect(findBadge().props('variant')).toBe('warning'); + expect(getTooltip().value).toBe(I18N_STALE_NEVER_CONTACTED_TOOLTIP); }); describe('does not fail when data is missing', () => { @@ -100,7 +113,7 @@ describe('RunnerTypeBadge', () => { }); expect(wrapper.text()).toBe('online'); - expect(getTooltip().value).toBe('Runner is online; last contact was n/a'); + expect(getTooltip().value).toBe('Runner is online; last contact was never'); }); it('status is missing', () => { diff --git a/spec/frontend/runner/components/runner_status_popover_spec.js b/spec/frontend/runner/components/runner_status_popover_spec.js new file mode 100644 index 00000000000..789283d1245 --- /dev/null +++ b/spec/frontend/runner/components/runner_status_popover_spec.js @@ -0,0 +1,36 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import RunnerStatusPopover from '~/runner/components/runner_status_popover.vue'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import { onlineContactTimeoutSecs, staleTimeoutSecs } from '../mock_data'; + +describe('RunnerStatusPopover', () => { + let wrapper; + + const createComponent = ({ provide = {} } = {}) => { + wrapper = shallowMountExtended(RunnerStatusPopover, { + provide: { + onlineContactTimeoutSecs, + staleTimeoutSecs, + ...provide, + }, + stubs: { + GlSprintf, + }, + }); + }; + + const findHelpPopover = () => wrapper.findComponent(HelpPopover); + + it('renders popoover', () => { + createComponent(); + + expect(findHelpPopover().exists()).toBe(true); + }); + + it('renders complete text', () => { + createComponent(); + + expect(findHelpPopover().text()).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/runner/graphql/local_state_spec.js b/spec/frontend/runner/graphql/local_state_spec.js new file mode 100644 index 00000000000..5c4302e4aa2 --- /dev/null +++ b/spec/frontend/runner/graphql/local_state_spec.js @@ -0,0 +1,72 @@ +import createApolloClient from '~/lib/graphql'; +import { createLocalState } from '~/runner/graphql/list/local_state'; +import getCheckedRunnerIdsQuery from '~/runner/graphql/list/checked_runner_ids.query.graphql'; + +describe('~/runner/graphql/list/local_state', () => { + let localState; + let apolloClient; + + const createSubject = () => { + if (apolloClient) { + throw new Error('test subject already exists!'); + } + + localState = createLocalState(); + + const { cacheConfig, typeDefs } = localState; + + apolloClient = createApolloClient({}, { cacheConfig, typeDefs }); + }; + + const queryCheckedRunnerIds = () => { + const { checkedRunnerIds } = apolloClient.readQuery({ + query: getCheckedRunnerIdsQuery, + }); + return checkedRunnerIds; + }; + + beforeEach(() => { + createSubject(); + }); + + afterEach(() => { + localState = null; + apolloClient = null; + }); + + describe('default', () => { + it('has empty checked list', () => { + expect(queryCheckedRunnerIds()).toEqual([]); + }); + }); + + describe.each` + inputs | expected + ${[['a', true], ['b', true], ['b', true]]} | ${['a', 'b']} + ${[['a', true], ['b', true], ['a', false]]} | ${['b']} + ${[['c', true], ['b', true], ['a', true], ['d', false]]} | ${['c', 'b', 'a']} + `('setRunnerChecked', ({ inputs, expected }) => { + beforeEach(() => { + inputs.forEach(([id, isChecked]) => { + localState.localMutations.setRunnerChecked({ runner: { id }, isChecked }); + }); + }); + it(`for inputs="${inputs}" has a ids="[${expected}]"`, () => { + expect(queryCheckedRunnerIds()).toEqual(expected); + }); + }); + + describe('clearChecked', () => { + it('clears all checked items', () => { + ['a', 'b', 'c'].forEach((id) => { + localState.localMutations.setRunnerChecked({ runner: { id }, isChecked: true }); + }); + + expect(queryCheckedRunnerIds()).toEqual(['a', 'b', 'c']); + + localState.localMutations.clearChecked(); + + expect(queryCheckedRunnerIds()).toEqual([]); + }); + }); +}); 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 70e303e8626..02348bf737a 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -28,8 +28,9 @@ import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE, + PARAM_KEY_PAUSED, PARAM_KEY_STATUS, - STATUS_ACTIVE, + STATUS_ONLINE, RUNNER_PAGE_SIZE, I18N_EDIT, } from '~/runner/constants'; @@ -38,7 +39,13 @@ import getGroupRunnersCountQuery from '~/runner/graphql/list/group_runners_count 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, groupRunnersCountData } from '../mock_data'; +import { + groupRunnersData, + groupRunnersDataPaginated, + groupRunnersCountData, + onlineContactTimeoutSecs, + staleTimeoutSecs, +} from '../mock_data'; Vue.use(VueApollo); Vue.use(GlToast); @@ -90,6 +97,10 @@ describe('GroupRunnersApp', () => { groupRunnersLimitedCount: mockGroupRunnersLimitedCount, ...props, }, + provide: { + onlineContactTimeoutSecs, + staleTimeoutSecs, + }, }); }; @@ -178,13 +189,16 @@ describe('GroupRunnersApp', () => { const tokens = findFilteredSearch().props('tokens'); - expect(tokens).toHaveLength(1); - expect(tokens[0]).toEqual( + expect(tokens).toEqual([ + expect.objectContaining({ + type: PARAM_KEY_PAUSED, + options: expect.any(Array), + }), expect.objectContaining({ type: PARAM_KEY_STATUS, options: expect.any(Array), }), - ); + ]); }); describe('Single runner row', () => { @@ -193,9 +207,11 @@ describe('GroupRunnersApp', () => { const { webUrl, editUrl, node } = mockGroupRunnersEdges[0]; const { id: graphqlId, shortSha } = node; const id = getIdFromGraphQLId(graphqlId); + const COUNT_QUERIES = 6; // Smart queries that display a filtered count of runners + const FILTERED_COUNT_QUERIES = 3; // Smart queries that display a count of runners in tabs beforeEach(async () => { - mockGroupRunnersQuery.mockClear(); + mockGroupRunnersCountQuery.mockClear(); createComponent({ mountFn: mountExtended }); showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show'); @@ -219,12 +235,20 @@ describe('GroupRunnersApp', () => { }); }); - it('When runner is deleted, data is refetched and a toast is shown', async () => { - expect(mockGroupRunnersQuery).toHaveBeenCalledTimes(1); + it('When runner is paused or unpaused, some data is refetched', async () => { + expect(mockGroupRunnersCountQuery).toHaveBeenCalledTimes(COUNT_QUERIES); - findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' }); + findRunnerActionsCell().vm.$emit('toggledPaused'); + + expect(mockGroupRunnersCountQuery).toHaveBeenCalledTimes( + COUNT_QUERIES + FILTERED_COUNT_QUERIES, + ); - expect(mockGroupRunnersQuery).toHaveBeenCalledTimes(2); + expect(showToast).toHaveBeenCalledTimes(0); + }); + + it('When runner is deleted, data is refetched and a toast message is shown', async () => { + findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' }); expect(showToast).toHaveBeenCalledTimes(1); expect(showToast).toHaveBeenCalledWith('Runner deleted'); @@ -233,7 +257,7 @@ describe('GroupRunnersApp', () => { describe('when a filter is preselected', () => { beforeEach(async () => { - setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`); + setWindowLocation(`?status[]=${STATUS_ONLINE}&runner_type[]=${INSTANCE_TYPE}`); createComponent(); await waitForPromises(); @@ -242,7 +266,7 @@ describe('GroupRunnersApp', () => { it('sets the filters in the search bar', () => { expect(findRunnerFilteredSearchBar().props('value')).toEqual({ runnerType: INSTANCE_TYPE, - filters: [{ type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }], + filters: [{ type: 'status', value: { data: STATUS_ONLINE, operator: '=' } }], sort: 'CREATED_DESC', pagination: { page: 1 }, }); @@ -251,7 +275,7 @@ describe('GroupRunnersApp', () => { it('requests the runners with filter parameters', () => { expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ groupFullPath: mockGroupFullPath, - status: STATUS_ACTIVE, + status: STATUS_ONLINE, type: INSTANCE_TYPE, sort: DEFAULT_SORT, first: RUNNER_PAGE_SIZE, @@ -263,7 +287,7 @@ describe('GroupRunnersApp', () => { beforeEach(async () => { findRunnerFilteredSearchBar().vm.$emit('input', { runnerType: null, - filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }], + filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], sort: CREATED_ASC, }); @@ -273,14 +297,14 @@ describe('GroupRunnersApp', () => { it('updates the browser url', () => { expect(updateHistory).toHaveBeenLastCalledWith({ title: expect.any(String), - url: 'http://test.host/groups/group1/-/runners?status[]=ACTIVE&sort=CREATED_ASC', + url: 'http://test.host/groups/group1/-/runners?status[]=ONLINE&sort=CREATED_ASC', }); }); it('requests the runners with filters', () => { expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ groupFullPath: mockGroupFullPath, - status: STATUS_ACTIVE, + status: STATUS_ONLINE, sort: CREATED_ASC, first: RUNNER_PAGE_SIZE, }); diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js index 49c25039719..fbe8926124c 100644 --- a/spec/frontend/runner/mock_data.js +++ b/spec/frontend/runner/mock_data.js @@ -14,6 +14,10 @@ import runnerWithGroupData from 'test_fixtures/graphql/runner/details/runner.que import runnerProjectsData from 'test_fixtures/graphql/runner/details/runner_projects.query.graphql.json'; import runnerJobsData from 'test_fixtures/graphql/runner/details/runner_jobs.query.graphql.json'; +// Other mock data +export const onlineContactTimeoutSecs = 2 * 60 * 60; +export const staleTimeoutSecs = 5259492; // Ruby's `2.months` + export { runnersData, runnersCountData, diff --git a/spec/frontend/runner/runner_search_utils_spec.js b/spec/frontend/runner/runner_search_utils_spec.js index aff1ec882bb..7834e76fe48 100644 --- a/spec/frontend/runner/runner_search_utils_spec.js +++ b/spec/frontend/runner/runner_search_utils_spec.js @@ -181,6 +181,28 @@ describe('search_params.js', () => { 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', () => { @@ -197,14 +219,18 @@ describe('search_params.js', () => { expect(updateOutdatedUrl('http://test.host/?a=b')).toBe(null); }); - it('returns updated url for updating NOT_CONNECTED to NEVER_CONTACTED', () => { - expect(updateOutdatedUrl('http://test.host/admin/runners?status[]=NOT_CONNECTED')).toBe( - 'http://test.host/admin/runners?status[]=NEVER_CONTACTED', - ); + it.each` + query | updatedQuery + ${'status[]=NOT_CONNECTED'} | ${'status[]=NEVER_CONTACTED'} + ${'status[]=NOT_CONNECTED&a=b'} | ${'status[]=NEVER_CONTACTED&a=b'} + ${'status[]=ACTIVE'} | ${'paused[]=false'} + ${'status[]=ACTIVE&a=b'} | ${'a=b&paused[]=false'} + ${'status[]=ACTIVE'} | ${'paused[]=false'} + ${'status[]=PAUSED'} | ${'paused[]=true'} + `('updates "$query" to "$updatedQuery"', ({ query, updatedQuery }) => { + const mockUrl = 'http://test.host/admin/runners?'; - expect(updateOutdatedUrl('http://test.host/admin/runners?status[]=NOT_CONNECTED&a=b')).toBe( - 'http://test.host/admin/runners?status[]=NEVER_CONTACTED&a=b', - ); + expect(updateOutdatedUrl(`${mockUrl}${query}`)).toBe(`${mockUrl}${updatedQuery}`); }); }); diff --git a/spec/frontend/runner/utils_spec.js b/spec/frontend/runner/utils_spec.js index 3fa9784ecdf..1db9815dfd8 100644 --- a/spec/frontend/runner/utils_spec.js +++ b/spec/frontend/runner/utils_spec.js @@ -44,6 +44,10 @@ describe('~/runner/utils', () => { thClass: expect.arrayContaining(mockClasses), }); }); + + it('a field with custom options', () => { + expect(tableField({ foo: 'bar' })).toMatchObject({ foo: 'bar' }); + }); }); describe('getPaginationVariables', () => { diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js index 5f8cee8160f..67bd3194f20 100644 --- a/spec/frontend/search/store/actions_spec.js +++ b/spec/frontend/search/store/actions_spec.js @@ -56,7 +56,7 @@ describe('Global Search Store Actions', () => { ${actions.fetchGroups} | ${{ method: 'onGet', code: 200, res: MOCK_GROUPS }} | ${'success'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_SUCCESS, payload: MOCK_GROUPS }]} | ${0} ${actions.fetchGroups} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_ERROR }]} | ${1} ${actions.fetchProjects} | ${{ method: 'onGet', code: 200, res: MOCK_PROJECTS }} | ${'success'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_SUCCESS, payload: MOCK_PROJECTS }]} | ${0} - ${actions.fetchProjects} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_ERROR }]} | ${2} + ${actions.fetchProjects} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_ERROR }]} | ${1} `(`axios calls`, ({ action, axiosMock, type, expectedMutations, flashCallCount }) => { describe(action.name, () => { describe(`on ${type}`, () => { @@ -121,8 +121,8 @@ describe('Global Search Store Actions', () => { describe('when groupId is set', () => { it('calls Api.groupProjects with expected parameters', () => { - actions.fetchProjects({ commit: mockCommit, state }); - + const callbackTest = jest.fn(); + actions.fetchProjects({ commit: mockCommit, state }, undefined, callbackTest); expect(Api.groupProjects).toHaveBeenCalledWith( state.query.group_id, state.query.search, @@ -131,7 +131,8 @@ describe('Global Search Store Actions', () => { include_subgroups: true, with_shared: false, }, - expect.any(Function), + callbackTest, + true, ); expect(Api.projects).not.toHaveBeenCalled(); }); @@ -144,15 +145,10 @@ describe('Global Search Store Actions', () => { it('calls Api.projects', () => { actions.fetchProjects({ commit: mockCommit, state }); - expect(Api.groupProjects).not.toHaveBeenCalled(); - expect(Api.projects).toHaveBeenCalledWith( - state.query.search, - { - order_by: 'similarity', - }, - expect.any(Function), - ); + expect(Api.projects).toHaveBeenCalledWith(state.query.search, { + order_by: 'similarity', + }); }); }); }); diff --git a/spec/frontend/search_autocomplete_spec.js b/spec/frontend/search_autocomplete_spec.js index c643cf6557d..190f2803324 100644 --- a/spec/frontend/search_autocomplete_spec.js +++ b/spec/frontend/search_autocomplete_spec.js @@ -223,34 +223,22 @@ describe('Search autocomplete dropdown', () => { }); } - it('suggest Projects', (done) => { - // eslint-disable-next-line promise/catch-or-return - triggerAutocomplete().finally(() => { - const list = widget.wrap.find('.dropdown-menu').find('ul'); - const link = "a[href$='/gitlab-org/gitlab-test']"; + it('suggest Projects', async () => { + await triggerAutocomplete(); - expect(list.find(link).length).toBe(1); + const list = widget.wrap.find('.dropdown-menu').find('ul'); + const link = "a[href$='/gitlab-org/gitlab-test']"; - done(); - }); - - // Make sure jest properly acknowledge the `done` invocation - jest.runOnlyPendingTimers(); + expect(list.find(link).length).toBe(1); }); - it('suggest Groups', (done) => { - // eslint-disable-next-line promise/catch-or-return - triggerAutocomplete().finally(() => { - const list = widget.wrap.find('.dropdown-menu').find('ul'); - const link = "a[href$='/gitlab-org']"; + it('suggest Groups', async () => { + await triggerAutocomplete(); - expect(list.find(link).length).toBe(1); - - done(); - }); + const list = widget.wrap.find('.dropdown-menu').find('ul'); + const link = "a[href$='/gitlab-org']"; - // Make sure jest properly acknowledge the `done` invocation - jest.runOnlyPendingTimers(); + expect(list.find(link).length).toBe(1); }); }); diff --git a/spec/frontend/search_settings/components/search_settings_spec.js b/spec/frontend/search_settings/components/search_settings_spec.js index 6beaea8dba5..d0a2018c7f0 100644 --- a/spec/frontend/search_settings/components/search_settings_spec.js +++ b/spec/frontend/search_settings/components/search_settings_spec.js @@ -1,5 +1,6 @@ import { GlSearchBoxByType } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { setHTMLFixture } from 'helpers/fixtures'; import SearchSettings from '~/search_settings/components/search_settings.vue'; import { HIGHLIGHT_CLASS, HIDE_CLASS } from '~/search_settings/constants'; import { isExpanded, expandSection, closeSection } from '~/settings_panels'; @@ -11,7 +12,8 @@ describe('search_settings/components/search_settings.vue', () => { const GENERAL_SETTINGS_ID = 'js-general-settings'; const ADVANCED_SETTINGS_ID = 'js-advanced-settings'; const EXTRA_SETTINGS_ID = 'js-extra-settings'; - const TEXT_CONTAIN_SEARCH_TERM = `This text contain ${SEARCH_TERM} and <script>alert("111")</script> others.`; + const TEXT_CONTAIN_SEARCH_TERM = `This text contain ${SEARCH_TERM}.`; + const TEXT_WITH_SIBLING_ELEMENTS = `${SEARCH_TERM} <a data-testid="sibling" href="#">Learn more</a>.`; let wrapper; @@ -42,13 +44,7 @@ describe('search_settings/components/search_settings.vue', () => { }); }; - const matchParentElement = () => { - const highlightedList = Array.from(document.querySelectorAll(`.${HIGHLIGHT_CLASS}`)); - return highlightedList.map((element) => { - return element.parentNode; - }); - }; - + const findMatchSiblingElement = () => document.querySelector(`[data-testid="sibling"]`); const findSearchBox = () => wrapper.find(GlSearchBoxByType); const search = (term) => { findSearchBox().vm.$emit('input', term); @@ -56,7 +52,7 @@ describe('search_settings/components/search_settings.vue', () => { const clearSearch = () => search(''); beforeEach(() => { - setFixtures(` + setHTMLFixture(` <div> <div class="js-search-app"></div> <div id="${ROOT_ID}"> @@ -69,6 +65,7 @@ describe('search_settings/components/search_settings.vue', () => { <section id="${EXTRA_SETTINGS_ID}" class="settings"> <span>${SEARCH_TERM}</span> <span>${TEXT_CONTAIN_SEARCH_TERM}</span> + <span>${TEXT_WITH_SIBLING_ELEMENTS}</span> </section> </div> </div> @@ -99,7 +96,7 @@ describe('search_settings/components/search_settings.vue', () => { it('highlight elements that match the search term', () => { search(SEARCH_TERM); - expect(highlightedElementsCount()).toBe(2); + expect(highlightedElementsCount()).toBe(3); }); it('highlight only search term and not the whole line', () => { @@ -108,14 +105,26 @@ describe('search_settings/components/search_settings.vue', () => { expect(highlightedTextNodes()).toBe(true); }); - it('prevents search xss', () => { + // Regression test for https://gitlab.com/gitlab-org/gitlab/-/issues/350494 + it('preserves elements that are siblings of matches', () => { + const snapshot = ` + <a + data-testid="sibling" + href="#" + > + Learn more + </a> + `; + + expect(findMatchSiblingElement()).toMatchInlineSnapshot(snapshot); + search(SEARCH_TERM); - const parentNodeList = matchParentElement(); - parentNodeList.forEach((element) => { - const scriptElement = element.getElementsByTagName('script'); - expect(scriptElement.length).toBe(0); - }); + expect(findMatchSiblingElement()).toMatchInlineSnapshot(snapshot); + + clearSearch(); + + expect(findMatchSiblingElement()).toMatchInlineSnapshot(snapshot); }); describe('default', () => { diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js index 963577fa763..9a18cb636b2 100644 --- a/spec/frontend/security_configuration/components/app_spec.js +++ b/spec/frontend/security_configuration/components/app_spec.js @@ -1,4 +1,4 @@ -import { GlTab, GlTabs } from '@gitlab/ui'; +import { GlTab, GlTabs, GlLink } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; @@ -33,6 +33,7 @@ const autoDevopsHelpPagePath = '/autoDevopsHelpPagePath'; const autoDevopsPath = '/autoDevopsPath'; const gitlabCiHistoryPath = 'test/historyPath'; const projectFullPath = 'namespace/project'; +const vulnerabilityTrainingDocsPath = 'user/application_security/vulnerabilities/index'; useLocalStorageSpy(); @@ -55,6 +56,7 @@ describe('App component', () => { autoDevopsHelpPagePath, autoDevopsPath, projectFullPath, + vulnerabilityTrainingDocsPath, glFeatures: { secureVulnerabilityTraining, }, @@ -107,6 +109,7 @@ describe('App component', () => { const findUpgradeBanner = () => wrapper.findComponent(UpgradeBanner); const findAutoDevopsAlert = () => wrapper.findComponent(AutoDevopsAlert); const findAutoDevopsEnabledAlert = () => wrapper.findComponent(AutoDevopsEnabledAlert); + const findVulnerabilityManagementTab = () => wrapper.findByTestId('vulnerability-management-tab'); const securityFeaturesMock = [ { @@ -454,9 +457,14 @@ describe('App component', () => { }); it('renders security training description', () => { - const vulnerabilityManagementTab = wrapper.findByTestId('vulnerability-management-tab'); + expect(findVulnerabilityManagementTab().text()).toContain(i18n.securityTrainingDescription); + }); + + it('renders link to help docs', () => { + const trainingLink = findVulnerabilityManagementTab().findComponent(GlLink); - expect(vulnerabilityManagementTab.text()).toContain(i18n.securityTrainingDescription); + expect(trainingLink.text()).toBe('Learn more about vulnerability training'); + expect(trainingLink.attributes('href')).toBe(vulnerabilityTrainingDocsPath); }); }); diff --git a/spec/frontend/security_configuration/components/feature_card_badge_spec.js b/spec/frontend/security_configuration/components/feature_card_badge_spec.js new file mode 100644 index 00000000000..dcde0808fa4 --- /dev/null +++ b/spec/frontend/security_configuration/components/feature_card_badge_spec.js @@ -0,0 +1,40 @@ +import { mount } from '@vue/test-utils'; +import { GlBadge, GlTooltip } from '@gitlab/ui'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import FeatureCardBadge from '~/security_configuration/components/feature_card_badge.vue'; + +describe('Feature card badge component', () => { + let wrapper; + + const createComponent = (propsData) => { + wrapper = extendedWrapper( + mount(FeatureCardBadge, { + propsData, + }), + ); + }; + + const findTooltip = () => wrapper.findComponent(GlTooltip); + const findBadge = () => wrapper.findComponent(GlBadge); + + describe('tooltip render', () => { + describe.each` + context | badge | badgeHref + ${'href on a badge object'} | ${{ tooltipText: 'test', badgeHref: 'href' }} | ${undefined} + ${'href as property '} | ${{ tooltipText: null, badgeHref: '' }} | ${'link'} + ${'default href no property on badge or component'} | ${{ tooltipText: null, badgeHref: '' }} | ${undefined} + `('given $context', ({ badge, badgeHref }) => { + beforeEach(() => { + createComponent({ badge, badgeHref }); + }); + + it('should show badge when badge given in configuration and available', () => { + expect(findTooltip().exists()).toBe(Boolean(badge && badge.tooltipText)); + }); + + it('should render correct link if link is provided', () => { + expect(findBadge().attributes().href).toEqual(badgeHref); + }); + }); + }); +}); diff --git a/spec/frontend/security_configuration/components/feature_card_spec.js b/spec/frontend/security_configuration/components/feature_card_spec.js index f0d902bf9fe..d10722be8ea 100644 --- a/spec/frontend/security_configuration/components/feature_card_spec.js +++ b/spec/frontend/security_configuration/components/feature_card_spec.js @@ -2,6 +2,7 @@ import { GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import FeatureCard from '~/security_configuration/components/feature_card.vue'; +import FeatureCardBadge from '~/security_configuration/components/feature_card_badge.vue'; import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue'; import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants'; import { makeFeature } from './utils'; @@ -16,6 +17,7 @@ describe('FeatureCard component', () => { propsData, stubs: { ManageViaMr: true, + FeatureCardBadge: true, }, }), ); @@ -24,6 +26,8 @@ describe('FeatureCard component', () => { const findLinks = ({ text, href }) => wrapper.findAll(`a[href="${href}"]`).filter((link) => link.text() === text); + const findBadge = () => wrapper.findComponent(FeatureCardBadge); + const findEnableLinks = () => findLinks({ text: `Enable ${feature.shortName ?? feature.name}`, @@ -262,5 +266,28 @@ describe('FeatureCard component', () => { }); }); }); + + describe('information badge', () => { + describe.each` + context | available | badge + ${'available feature with badge'} | ${true} | ${{ text: 'test' }} + ${'unavailable feature without badge'} | ${false} | ${null} + ${'available feature without badge'} | ${true} | ${null} + ${'unavailable feature with badge'} | ${false} | ${{ text: 'test' }} + ${'available feature with empty badge'} | ${false} | ${{}} + `('given $context', ({ available, badge }) => { + beforeEach(() => { + feature = makeFeature({ + available, + badge, + }); + createComponent({ feature }); + }); + + it('should show badge when badge given in configuration and available', () => { + expect(findBadge().exists()).toBe(Boolean(available && badge && badge.text)); + }); + }); + }); }); }); 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 b8c1bef0ddd..309a9cd4cd6 100644 --- a/spec/frontend/security_configuration/components/training_provider_list_spec.js +++ b/spec/frontend/security_configuration/components/training_provider_list_spec.js @@ -1,5 +1,13 @@ import * as Sentry from '@sentry/browser'; -import { GlAlert, GlLink, GlToggle, GlCard, GlSkeletonLoader, GlIcon } from '@gitlab/ui'; +import { + GlAlert, + GlLink, + GlFormRadio, + GlToggle, + GlCard, + GlSkeletonLoader, + GlIcon, +} from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -87,7 +95,7 @@ describe('TrainingProviderList component', () => { const findLinks = () => wrapper.findAllComponents(GlLink); const findToggles = () => wrapper.findAllComponents(GlToggle); const findFirstToggle = () => findToggles().at(0); - const findPrimaryProviderRadios = () => wrapper.findAllByTestId('primary-provider-radio'); + const findPrimaryProviderRadios = () => wrapper.findAllComponents(GlFormRadio); const findLoader = () => wrapper.findComponent(GlSkeletonLoader); const findErrorAlert = () => wrapper.findComponent(GlAlert); const findLogos = () => wrapper.findAllByTestId('provider-logo'); @@ -177,8 +185,8 @@ describe('TrainingProviderList component', () => { const primaryProviderRadioForCurrentCard = findPrimaryProviderRadios().at(index); // if the given provider is not enabled it should not be possible select it as primary - expect(primaryProviderRadioForCurrentCard.find('input').attributes('disabled')).toBe( - isEnabled ? undefined : 'disabled', + expect(primaryProviderRadioForCurrentCard.attributes('disabled')).toBe( + isEnabled ? undefined : 'true', ); expect(primaryProviderRadioForCurrentCard.text()).toBe( diff --git a/spec/frontend/self_monitor/store/actions_spec.js b/spec/frontend/self_monitor/store/actions_spec.js index 6bcb2a713ea..59ee87c4a02 100644 --- a/spec/frontend/self_monitor/store/actions_spec.js +++ b/spec/frontend/self_monitor/store/actions_spec.js @@ -16,27 +16,25 @@ describe('self monitor actions', () => { }); describe('setSelfMonitor', () => { - it('commits the SET_ENABLED mutation', (done) => { - testAction( + it('commits the SET_ENABLED mutation', () => { + return testAction( actions.setSelfMonitor, null, state, [{ type: types.SET_ENABLED, payload: null }], [], - done, ); }); }); describe('resetAlert', () => { - it('commits the SET_ENABLED mutation', (done) => { - testAction( + it('commits the SET_ENABLED mutation', () => { + return testAction( actions.resetAlert, null, state, [{ type: types.SET_SHOW_ALERT, payload: false }], [], - done, ); }); }); @@ -54,8 +52,8 @@ describe('self monitor actions', () => { }); }); - it('dispatches status request with job data', (done) => { - testAction( + it('dispatches status request with job data', () => { + return testAction( actions.requestCreateProject, null, state, @@ -71,12 +69,11 @@ describe('self monitor actions', () => { payload: '123', }, ], - done, ); }); - it('dispatches success with project path', (done) => { - testAction( + it('dispatches success with project path', () => { + return testAction( actions.requestCreateProjectStatus, null, state, @@ -87,7 +84,6 @@ describe('self monitor actions', () => { payload: { project_full_path: '/self-monitor-url' }, }, ], - done, ); }); }); @@ -98,8 +94,8 @@ describe('self monitor actions', () => { mock.onPost(state.createProjectEndpoint).reply(500); }); - it('dispatches error', (done) => { - testAction( + it('dispatches error', () => { + return testAction( actions.requestCreateProject, null, state, @@ -115,14 +111,13 @@ describe('self monitor actions', () => { payload: new Error('Request failed with status code 500'), }, ], - done, ); }); }); describe('requestCreateProjectSuccess', () => { - it('should commit the received data', (done) => { - testAction( + it('should commit the received data', () => { + return testAction( actions.requestCreateProjectSuccess, { project_full_path: '/self-monitor-url' }, state, @@ -146,7 +141,6 @@ describe('self monitor actions', () => { type: 'setSelfMonitor', }, ], - done, ); }); }); @@ -165,8 +159,8 @@ describe('self monitor actions', () => { }); }); - it('dispatches status request with job data', (done) => { - testAction( + it('dispatches status request with job data', () => { + return testAction( actions.requestDeleteProject, null, state, @@ -182,12 +176,11 @@ describe('self monitor actions', () => { payload: '456', }, ], - done, ); }); - it('dispatches success with status', (done) => { - testAction( + it('dispatches success with status', () => { + return testAction( actions.requestDeleteProjectStatus, null, state, @@ -198,7 +191,6 @@ describe('self monitor actions', () => { payload: { status: 'success' }, }, ], - done, ); }); }); @@ -209,8 +201,8 @@ describe('self monitor actions', () => { mock.onDelete(state.deleteProjectEndpoint).reply(500); }); - it('dispatches error', (done) => { - testAction( + it('dispatches error', () => { + return testAction( actions.requestDeleteProject, null, state, @@ -226,14 +218,13 @@ describe('self monitor actions', () => { payload: new Error('Request failed with status code 500'), }, ], - done, ); }); }); describe('requestDeleteProjectSuccess', () => { - it('should commit mutations to remove previously set data', (done) => { - testAction( + it('should commit mutations to remove previously set data', () => { + return testAction( actions.requestDeleteProjectSuccess, null, state, @@ -252,7 +243,6 @@ describe('self monitor actions', () => { { type: types.SET_LOADING, payload: false }, ], [], - done, ); }); }); diff --git a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap index f57b9418be5..0f4dfdf8a75 100644 --- a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap +++ b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap @@ -3,7 +3,7 @@ exports[`EmptyStateComponent should render content 1`] = ` "<section class=\\"gl-display-flex empty-state gl-text-center gl-flex-direction-column\\"> <div class=\\"gl-max-w-full\\"> - <div class=\\"svg-250 svg-content\\"><img src=\\"/image.svg\\" alt=\\"\\" role=\\"img\\" class=\\"gl-max-w-full\\"></div> + <div class=\\"svg-250 svg-content\\"><img src=\\"/image.svg\\" alt=\\"\\" role=\\"img\\" class=\\"gl-max-w-full gl-dark-invert-keep-hue\\"></div> </div> <div class=\\"gl-max-w-full gl-m-auto\\"> <div class=\\"gl-mx-auto gl-my-0 gl-p-5\\"> diff --git a/spec/frontend/serverless/store/actions_spec.js b/spec/frontend/serverless/store/actions_spec.js index 61b9bd121af..5fbecf081a6 100644 --- a/spec/frontend/serverless/store/actions_spec.js +++ b/spec/frontend/serverless/store/actions_spec.js @@ -7,13 +7,22 @@ import { mockServerlessFunctions, mockMetrics } from '../mock_data'; import { adjustMetricQuery } from '../utils'; describe('ServerlessActions', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + describe('fetchFunctions', () => { - it('should successfully fetch functions', (done) => { + it('should successfully fetch functions', () => { const endpoint = '/functions'; - const mock = new MockAdapter(axios); mock.onGet(endpoint).reply(statusCodes.OK, JSON.stringify(mockServerlessFunctions)); - testAction( + return testAction( fetchFunctions, { functionsPath: endpoint }, {}, @@ -22,68 +31,49 @@ describe('ServerlessActions', () => { { type: 'requestFunctionsLoading' }, { type: 'receiveFunctionsSuccess', payload: mockServerlessFunctions }, ], - () => { - mock.restore(); - done(); - }, ); }); - it('should successfully retry', (done) => { + it('should successfully retry', () => { const endpoint = '/functions'; - const mock = new MockAdapter(axios); mock .onGet(endpoint) .reply(() => new Promise((resolve) => setTimeout(() => resolve(200), Infinity))); - testAction( + return testAction( fetchFunctions, { functionsPath: endpoint }, {}, [], [{ type: 'requestFunctionsLoading' }], - () => { - mock.restore(); - done(); - }, ); }); }); describe('fetchMetrics', () => { - it('should return no prometheus', (done) => { + it('should return no prometheus', () => { const endpoint = '/metrics'; - const mock = new MockAdapter(axios); mock.onGet(endpoint).reply(statusCodes.NO_CONTENT); - testAction( + return testAction( fetchMetrics, { metricsPath: endpoint, hasPrometheus: false }, {}, [], [{ type: 'receiveMetricsNoPrometheus' }], - () => { - mock.restore(); - done(); - }, ); }); - it('should successfully fetch metrics', (done) => { + it('should successfully fetch metrics', () => { const endpoint = '/metrics'; - const mock = new MockAdapter(axios); mock.onGet(endpoint).reply(statusCodes.OK, JSON.stringify(mockMetrics)); - testAction( + return testAction( fetchMetrics, { metricsPath: endpoint, hasPrometheus: true }, {}, [], [{ type: 'receiveMetricsSuccess', payload: adjustMetricQuery(mockMetrics) }], - () => { - mock.restore(); - done(); - }, ); }); }); 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 c105810e11c..0b672cbc93e 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 @@ -26,7 +26,7 @@ describe('SetStatusModalWrapper', () => { defaultEmoji, }; - const createComponent = (props = {}, improvedEmojiPicker = false) => { + const createComponent = (props = {}) => { return shallowMount(SetStatusModalWrapper, { propsData: { ...defaultProps, @@ -35,19 +35,15 @@ describe('SetStatusModalWrapper', () => { mocks: { $toast, }, - provide: { - glFeatures: { improvedEmojiPicker }, - }, }); }; const findModal = () => wrapper.find(GlModal); const findFormField = (field) => wrapper.find(`[name="user[status][${field}]"]`); const findClearStatusButton = () => wrapper.find('.js-clear-user-status-button'); - const findNoEmojiPlaceholder = () => wrapper.find('.js-no-emoji-placeholder'); - const findToggleEmojiButton = () => wrapper.find('.js-toggle-emoji-menu'); const findAvailabilityCheckbox = () => wrapper.find(GlFormCheckbox); const findClearStatusAtMessage = () => wrapper.find('[data-testid="clear-status-at-message"]'); + const getEmojiPicker = () => wrapper.findComponent(EmojiPicker); const initModal = async ({ mockOnUpdateSuccess = true, mockOnUpdateFailure = true } = {}) => { const modal = findModal(); @@ -95,12 +91,6 @@ describe('SetStatusModalWrapper', () => { expect(findClearStatusButton().isVisible()).toBe(true); }); - it('clicking the toggle emoji button displays the emoji list', () => { - expect(wrapper.vm.showEmojiMenu).not.toHaveBeenCalled(); - findToggleEmojiButton().trigger('click'); - expect(wrapper.vm.showEmojiMenu).toHaveBeenCalled(); - }); - it('displays the clear status at dropdown', () => { expect(wrapper.find('[data-testid="clear-status-at-dropdown"]').exists()).toBe(true); }); @@ -108,16 +98,6 @@ describe('SetStatusModalWrapper', () => { it('does not display the clear status at message', () => { expect(findClearStatusAtMessage().exists()).toBe(false); }); - }); - - describe('improvedEmojiPicker is true', () => { - const getEmojiPicker = () => wrapper.findComponent(EmojiPicker); - - beforeEach(async () => { - await initEmojiMock(); - wrapper = createComponent({}, true); - return initModal(); - }); it('renders emoji picker dropdown with custom positioning', () => { expect(getEmojiPicker().props()).toMatchObject({ @@ -147,10 +127,6 @@ describe('SetStatusModalWrapper', () => { it('hides the clear status button', () => { expect(findClearStatusButton().isVisible()).toBe(false); }); - - it('shows the placeholder emoji', () => { - expect(findNoEmojiPlaceholder().isVisible()).toBe(true); - }); }); describe('with no currentEmoji set', () => { @@ -163,22 +139,6 @@ describe('SetStatusModalWrapper', () => { it('does not set the hidden status emoji field', () => { expect(findFormField('emoji').element.value).toBe(''); }); - - it('hides the placeholder emoji', () => { - expect(findNoEmojiPlaceholder().isVisible()).toBe(false); - }); - - describe('with no currentMessage set', () => { - beforeEach(async () => { - await initEmojiMock(); - wrapper = createComponent({ currentEmoji: '', currentMessage: '' }); - return initModal(); - }); - - it('shows the placeholder emoji', () => { - expect(findNoEmojiPlaceholder().isVisible()).toBe(true); - }); - }); }); describe('with currentClearStatusAfter set', () => { diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/shortcuts_spec.js index 49148123a1c..8b9a11056f2 100644 --- a/spec/frontend/shortcuts_spec.js +++ b/spec/frontend/shortcuts_spec.js @@ -41,7 +41,7 @@ describe('Shortcuts', () => { ).toHaveBeenCalled(); }); - it('focues preview button inside edit comment form', () => { + it('focuses preview button inside edit comment form', () => { document.querySelector('.js-note-edit').click(); Shortcuts.toggleMarkdownPreview( diff --git a/spec/frontend/sidebar/assignees_realtime_spec.js b/spec/frontend/sidebar/assignees_realtime_spec.js index 2249a1c08b8..ae8f07bf901 100644 --- a/spec/frontend/sidebar/assignees_realtime_spec.js +++ b/spec/frontend/sidebar/assignees_realtime_spec.js @@ -2,11 +2,16 @@ 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 AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import getIssueAssigneesQuery from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql'; -import Mock, { issuableQueryResponse, subscriptionNullResponse } from './mock_data'; +import Mock, { + issuableQueryResponse, + subscriptionNullResponse, + subscriptionResponse, +} from './mock_data'; Vue.use(VueApollo); @@ -20,7 +25,6 @@ describe('Assignees Realtime', () => { const createComponent = ({ issuableType = 'issue', - issuableId = 1, subscriptionHandler = subscriptionInitialHandler, } = {}) => { fakeApollo = createMockApollo([ @@ -30,7 +34,6 @@ describe('Assignees Realtime', () => { wrapper = shallowMount(AssigneesRealtime, { propsData: { issuableType, - issuableId, queryVariables: { issuableIid: '1', projectPath: 'path/to/project', @@ -60,11 +63,23 @@ describe('Assignees Realtime', () => { }); }); - it('calls the subscription with correct variable for issue', () => { + it('calls the subscription with correct variable for issue', async () => { createComponent(); + await waitForPromises(); expect(subscriptionInitialHandler).toHaveBeenCalledWith({ issuableId: 'gid://gitlab/Issue/1', }); }); + + it('emits an `assigneesUpdated` event on subscription response', async () => { + createComponent({ + subscriptionHandler: jest.fn().mockResolvedValue(subscriptionResponse), + }); + await waitForPromises(); + + expect(wrapper.emitted('assigneesUpdated')).toEqual([ + [{ id: '1', assignees: subscriptionResponse.data.issuableAssigneesUpdated.assignees.nodes }], + ]); + }); }); diff --git a/spec/frontend/sidebar/components/incidents/escalation_status_spec.js b/spec/frontend/sidebar/components/incidents/escalation_status_spec.js index 7a736624fc0..8d8c10d10f1 100644 --- a/spec/frontend/sidebar/components/incidents/escalation_status_spec.js +++ b/spec/frontend/sidebar/components/incidents/escalation_status_spec.js @@ -1,4 +1,5 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import EscalationStatus from '~/sidebar/components/incidents/escalation_status.vue'; import { @@ -25,6 +26,11 @@ describe('EscalationStatus', () => { const findDropdownComponent = () => wrapper.findComponent(GlDropdown); const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownMenu = () => findDropdownComponent().find('.dropdown-menu'); + const toggleDropdown = async () => { + await findDropdownComponent().findComponent('button').trigger('click'); + await waitForPromises(); + }; describe('status', () => { it('shows the current status', () => { @@ -49,4 +55,32 @@ describe('EscalationStatus', () => { expect(wrapper.emitted().input[0][0]).toBe(STATUS_ACKNOWLEDGED); }); }); + + describe('close behavior', () => { + it('allows the dropdown to be closed by default', async () => { + createComponent(); + // Open dropdown + await toggleDropdown(); + + expect(findDropdownMenu().classes('show')).toBe(true); + + // Attempt to close dropdown + await toggleDropdown(); + + expect(findDropdownMenu().classes('show')).toBe(false); + }); + + it('preventDropdownClose prevents the dropdown from closing', async () => { + createComponent({ preventDropdownClose: true }); + // Open dropdown + await toggleDropdown(); + + expect(findDropdownMenu().classes('show')).toBe(true); + + // Attempt to close dropdown + await toggleDropdown(); + + expect(findDropdownMenu().classes('show')).toBe(true); + }); + }); }); diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js index fbca00636b6..2b421037339 100644 --- a/spec/frontend/sidebar/mock_data.js +++ b/spec/frontend/sidebar/mock_data.js @@ -415,6 +415,28 @@ export const subscriptionNullResponse = { }, }; +export const subscriptionResponse = { + data: { + issuableAssigneesUpdated: { + id: '1', + assignees: { + nodes: [ + { + __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, + }, + ], + }, + }, + }, +}; + const mockUser1 = { __typename: 'UserCore', id: 'gid://gitlab/User/1', diff --git a/spec/frontend/sidebar/participants_spec.js b/spec/frontend/sidebar/participants_spec.js index 356628849d9..2517b625225 100644 --- a/spec/frontend/sidebar/participants_spec.js +++ b/spec/frontend/sidebar/participants_spec.js @@ -17,8 +17,7 @@ const PARTICIPANT_LIST = [PARTICIPANT, { ...PARTICIPANT, id: 2 }, { ...PARTICIPA describe('Participants', () => { let wrapper; - const getMoreParticipantsButton = () => wrapper.find('button'); - + const getMoreParticipantsButton = () => wrapper.find('[data-testid="more-participants"]'); const getCollapsedParticipantsCount = () => wrapper.find('[data-testid="collapsed-count"]'); const mountComponent = (propsData) => @@ -167,7 +166,7 @@ describe('Participants', () => { expect(wrapper.vm.isShowingMoreParticipants).toBe(false); - getMoreParticipantsButton().trigger('click'); + getMoreParticipantsButton().vm.$emit('click'); expect(wrapper.vm.isShowingMoreParticipants).toBe(true); }); diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js index 61424fa1eb2..9cfe136129a 100644 --- a/spec/frontend/snippets/components/edit_spec.js +++ b/spec/frontend/snippets/components/edit_spec.js @@ -19,8 +19,8 @@ import { SNIPPET_VISIBILITY_INTERNAL, SNIPPET_VISIBILITY_PUBLIC, } from '~/snippets/constants'; -import CreateSnippetMutation from '~/snippets/mutations/createSnippet.mutation.graphql'; -import UpdateSnippetMutation from '~/snippets/mutations/updateSnippet.mutation.graphql'; +import CreateSnippetMutation from '~/snippets/mutations/create_snippet.mutation.graphql'; +import UpdateSnippetMutation from '~/snippets/mutations/update_snippet.mutation.graphql'; import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue'; import TitleField from '~/vue_shared/components/form/title.vue'; import { testEntries, createGQLSnippetsQueryResponse, createGQLSnippet } from '../test_utils'; diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js index 1b9d170556b..b750225a383 100644 --- a/spec/frontend/snippets/components/snippet_header_spec.js +++ b/spec/frontend/snippets/components/snippet_header_spec.js @@ -8,7 +8,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { Blob, BinaryBlob } from 'jest/blob/components/mock_data'; import { differenceInMilliseconds } from '~/lib/utils/datetime_utility'; import SnippetHeader, { i18n } from '~/snippets/components/snippet_header.vue'; -import DeleteSnippetMutation from '~/snippets/mutations/deleteSnippet.mutation.graphql'; +import DeleteSnippetMutation from '~/snippets/mutations/delete_snippet.mutation.graphql'; import axios from '~/lib/utils/axios_utils'; import createFlash, { FLASH_TYPES } from '~/flash'; diff --git a/spec/frontend/task_list_spec.js b/spec/frontend/task_list_spec.js index bf470e7e126..fbdb73ae6de 100644 --- a/spec/frontend/task_list_spec.js +++ b/spec/frontend/task_list_spec.js @@ -121,7 +121,7 @@ describe('TaskList', () => { }); describe('update', () => { - it('should disable task list items and make a patch request then enable them again', (done) => { + it('should disable task list items and make a patch request then enable them again', () => { const response = { data: { lock_version: 3 } }; jest.spyOn(taskList, 'enableTaskListItems').mockImplementation(() => {}); jest.spyOn(taskList, 'disableTaskListItems').mockImplementation(() => {}); @@ -156,20 +156,17 @@ describe('TaskList', () => { expect(taskList.onUpdate).toHaveBeenCalled(); - update - .then(() => { - expect(taskList.disableTaskListItems).toHaveBeenCalledWith(event); - expect(axios.patch).toHaveBeenCalledWith(endpoint, patchData); - expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event); - expect(taskList.onSuccess).toHaveBeenCalledWith(response.data); - expect(taskList.lockVersion).toEqual(response.data.lock_version); - }) - .then(done) - .catch(done.fail); + return update.then(() => { + expect(taskList.disableTaskListItems).toHaveBeenCalledWith(event); + expect(axios.patch).toHaveBeenCalledWith(endpoint, patchData); + expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event); + expect(taskList.onSuccess).toHaveBeenCalledWith(response.data); + expect(taskList.lockVersion).toEqual(response.data.lock_version); + }); }); }); - it('should handle request error and enable task list items', (done) => { + it('should handle request error and enable task list items', () => { const response = { data: { error: 1 } }; jest.spyOn(taskList, 'enableTaskListItems').mockImplementation(() => {}); jest.spyOn(taskList, 'onUpdate').mockImplementation(() => {}); @@ -182,12 +179,9 @@ describe('TaskList', () => { expect(taskList.onUpdate).toHaveBeenCalled(); - update - .then(() => { - expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event); - expect(taskList.onError).toHaveBeenCalledWith(response.data); - }) - .then(done) - .catch(done.fail); + return update.then(() => { + expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event); + expect(taskList.onError).toHaveBeenCalledWith(response.data); + }); }); }); diff --git a/spec/frontend/terraform/components/empty_state_spec.js b/spec/frontend/terraform/components/empty_state_spec.js index b1303cf2b5e..21bfff5f1be 100644 --- a/spec/frontend/terraform/components/empty_state_spec.js +++ b/spec/frontend/terraform/components/empty_state_spec.js @@ -13,15 +13,20 @@ describe('EmptyStateComponent', () => { const findLink = () => wrapper.findComponent(GlLink); beforeEach(() => { - wrapper = shallowMount(EmptyState, { propsData, stubs: { GlEmptyState, GlLink } }); + wrapper = shallowMount(EmptyState, { propsData }); + }); + + afterEach(() => { + wrapper.destroy(); }); it('should render content', () => { - expect(findEmptyState().exists()).toBe(true); - expect(wrapper.text()).toContain('Get started with Terraform'); + expect(findEmptyState().props('title')).toBe( + "Your project doesn't have any Terraform state files", + ); }); - it('should have a link to the GitLab managed Terraform States docs', () => { + it('should have a link to the GitLab managed Terraform states docs', () => { expect(findLink().attributes('href')).toBe(docsUrl); }); }); diff --git a/spec/frontend/terraform/components/mock_data.js b/spec/frontend/terraform/components/mock_data.js new file mode 100644 index 00000000000..f0109047d4c --- /dev/null +++ b/spec/frontend/terraform/components/mock_data.js @@ -0,0 +1,35 @@ +export const getStatesResponse = { + data: { + project: { + id: 'project-1', + terraformStates: { + count: 1, + nodes: { + _showDetails: true, + errorMessages: [], + loadingLock: false, + loadingRemove: false, + id: 'state-1', + name: 'state', + lockedAt: '01-01-2022', + updatedAt: '01-01-2022', + lockedByUser: { + id: 'user-1', + avatarUrl: 'avatar', + name: 'User 1', + username: 'user-1', + webUrl: 'web', + }, + latestVersion: null, + }, + pageInfo: { + __typename: 'PageInfo', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'prev', + endCursor: 'next', + }, + }, + }, + }, +}; diff --git a/spec/frontend/terraform/components/states_table_actions_spec.js b/spec/frontend/terraform/components/states_table_actions_spec.js index a6c80b95af4..d01f6af9023 100644 --- a/spec/frontend/terraform/components/states_table_actions_spec.js +++ b/spec/frontend/terraform/components/states_table_actions_spec.js @@ -9,6 +9,8 @@ import StateActions from '~/terraform/components/states_table_actions.vue'; import lockStateMutation from '~/terraform/graphql/mutations/lock_state.mutation.graphql'; import removeStateMutation from '~/terraform/graphql/mutations/remove_state.mutation.graphql'; import unlockStateMutation from '~/terraform/graphql/mutations/unlock_state.mutation.graphql'; +import getStatesQuery from '~/terraform/graphql/queries/get_states.query.graphql'; +import { getStatesResponse } from './mock_data'; Vue.use(VueApollo); @@ -49,6 +51,7 @@ describe('StatesTableActions', () => { [lockStateMutation, lockResponse], [removeStateMutation, removeResponse], [unlockStateMutation, unlockResponse], + [getStatesQuery, jest.fn().mockResolvedValue(getStatesResponse)], ], { Mutation: { diff --git a/spec/frontend/tracking/tracking_spec.js b/spec/frontend/tracking/tracking_spec.js index d85299cdfc3..665bf44fc77 100644 --- a/spec/frontend/tracking/tracking_spec.js +++ b/spec/frontend/tracking/tracking_spec.js @@ -129,6 +129,72 @@ describe('Tracking', () => { }); }); + describe('.definition', () => { + const TEST_VALID_BASENAME = '202108302307_default_click_button'; + const TEST_EVENT_DATA = { category: undefined, action: 'click_button' }; + let eventSpy; + let dispatcherSpy; + + beforeAll(() => { + Tracking.definitionsManifest = { + '202108302307_default_click_button': 'config/events/202108302307_default_click_button.yml', + }; + }); + + beforeEach(() => { + eventSpy = jest.spyOn(Tracking, 'event'); + dispatcherSpy = jest.spyOn(Tracking, 'dispatchFromDefinition'); + }); + + it('throws an error if the definition does not exists', () => { + const basename = '20220230_default_missing_definition'; + const expectedError = new Error(`Missing Snowplow event definition "${basename}"`); + + expect(() => Tracking.definition(basename)).toThrow(expectedError); + }); + + it('dispatches an event from a definition present in the manifest', () => { + Tracking.definition(TEST_VALID_BASENAME); + + expect(dispatcherSpy).toHaveBeenCalledWith(TEST_VALID_BASENAME, {}); + }); + + it('push events to the queue if not loaded', () => { + Tracking.definitionsLoaded = false; + Tracking.definitionsEventsQueue = []; + + const dispatched = Tracking.definition(TEST_VALID_BASENAME); + + expect(dispatched).toBe(false); + expect(Tracking.definitionsEventsQueue[0]).toStrictEqual([TEST_VALID_BASENAME, {}]); + expect(eventSpy).not.toHaveBeenCalled(); + }); + + it('dispatch events when the definition is loaded', () => { + const definition = { key: TEST_VALID_BASENAME, ...TEST_EVENT_DATA }; + Tracking.definitions = [{ ...definition }]; + Tracking.definitionsEventsQueue = []; + Tracking.definitionsLoaded = true; + + const dispatched = Tracking.definition(TEST_VALID_BASENAME); + + expect(dispatched).not.toBe(false); + expect(Tracking.definitionsEventsQueue).toEqual([]); + expect(eventSpy).toHaveBeenCalledWith(definition.category, definition.action, {}); + }); + + it('lets defined event data takes precedence', () => { + const definition = { key: TEST_VALID_BASENAME, category: undefined, action: 'click_button' }; + const eventData = { category: TEST_CATEGORY }; + Tracking.definitions = [{ ...definition }]; + Tracking.definitionsLoaded = true; + + Tracking.definition(TEST_VALID_BASENAME, eventData); + + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, definition.action, eventData); + }); + }); + describe('.enableFormTracking', () => { it('tells snowplow to enable form tracking, with only explicit contexts', () => { const config = { forms: { allow: ['form-class1'] }, fields: { allow: ['input-class1'] } }; diff --git a/spec/frontend/user_lists/components/edit_user_list_spec.js b/spec/frontend/user_lists/components/edit_user_list_spec.js index 7cafe5e1f56..941c8244247 100644 --- a/spec/frontend/user_lists/components/edit_user_list_spec.js +++ b/spec/frontend/user_lists/components/edit_user_list_spec.js @@ -8,7 +8,7 @@ import { redirectTo } from '~/lib/utils/url_utility'; import EditUserList from '~/user_lists/components/edit_user_list.vue'; import UserListForm from '~/user_lists/components/user_list_form.vue'; import createStore from '~/user_lists/store/edit'; -import { userList } from '../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('~/api'); jest.mock('~/lib/utils/url_utility'); diff --git a/spec/frontend/user_lists/components/new_user_list_spec.js b/spec/frontend/user_lists/components/new_user_list_spec.js index 5eb44970fe4..ace4a284347 100644 --- a/spec/frontend/user_lists/components/new_user_list_spec.js +++ b/spec/frontend/user_lists/components/new_user_list_spec.js @@ -7,7 +7,7 @@ import Api from '~/api'; import { redirectTo } from '~/lib/utils/url_utility'; import NewUserList from '~/user_lists/components/new_user_list.vue'; import createStore from '~/user_lists/store/new'; -import { userList } from '../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('~/api'); jest.mock('~/lib/utils/url_utility'); diff --git a/spec/frontend/user_lists/components/user_list_form_spec.js b/spec/frontend/user_lists/components/user_list_form_spec.js index 42f7659600e..e09d8eac32f 100644 --- a/spec/frontend/user_lists/components/user_list_form_spec.js +++ b/spec/frontend/user_lists/components/user_list_form_spec.js @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils'; import Form from '~/user_lists/components/user_list_form.vue'; -import { userList } from '../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; describe('user_lists/components/user_list_form', () => { let wrapper; diff --git a/spec/frontend/user_lists/components/user_list_spec.js b/spec/frontend/user_lists/components/user_list_spec.js index 88dad06938b..f126c733dd5 100644 --- a/spec/frontend/user_lists/components/user_list_spec.js +++ b/spec/frontend/user_lists/components/user_list_spec.js @@ -7,7 +7,7 @@ import Api from '~/api'; import UserList from '~/user_lists/components/user_list.vue'; import createStore from '~/user_lists/store/show'; import { parseUserIds, stringifyUserIds } from '~/user_lists/store/utils'; -import { userList } from '../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('~/api'); diff --git a/spec/frontend/user_lists/components/user_lists_spec.js b/spec/frontend/user_lists/components/user_lists_spec.js index 10742c029c1..161eb036361 100644 --- a/spec/frontend/user_lists/components/user_lists_spec.js +++ b/spec/frontend/user_lists/components/user_lists_spec.js @@ -9,7 +9,7 @@ import UserListsComponent from '~/user_lists/components/user_lists.vue'; import UserListsTable from '~/user_lists/components/user_lists_table.vue'; import createStore from '~/user_lists/store/index'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; -import { userList } from '../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('~/api'); diff --git a/spec/frontend/user_lists/components/user_lists_table_spec.js b/spec/frontend/user_lists/components/user_lists_table_spec.js index 63587703392..08eb8ae0843 100644 --- a/spec/frontend/user_lists/components/user_lists_table_spec.js +++ b/spec/frontend/user_lists/components/user_lists_table_spec.js @@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils'; import * as timeago from 'timeago.js'; import { nextTick } from 'vue'; import UserListsTable from '~/user_lists/components/user_lists_table.vue'; -import { userList } from '../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('timeago.js', () => ({ format: jest.fn().mockReturnValue('2 weeks ago'), diff --git a/spec/frontend/user_lists/store/edit/actions_spec.js b/spec/frontend/user_lists/store/edit/actions_spec.js index c4b0f888d3e..ca56c935ea5 100644 --- a/spec/frontend/user_lists/store/edit/actions_spec.js +++ b/spec/frontend/user_lists/store/edit/actions_spec.js @@ -4,7 +4,7 @@ import { redirectTo } from '~/lib/utils/url_utility'; import * as actions from '~/user_lists/store/edit/actions'; import * as types from '~/user_lists/store/edit/mutation_types'; import createState from '~/user_lists/store/edit/state'; -import { userList } from '../../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('~/api'); jest.mock('~/lib/utils/url_utility'); diff --git a/spec/frontend/user_lists/store/edit/mutations_spec.js b/spec/frontend/user_lists/store/edit/mutations_spec.js index 0943c64e934..7971906429b 100644 --- a/spec/frontend/user_lists/store/edit/mutations_spec.js +++ b/spec/frontend/user_lists/store/edit/mutations_spec.js @@ -2,7 +2,7 @@ import statuses from '~/user_lists/constants/edit'; import * as types from '~/user_lists/store/edit/mutation_types'; import mutations from '~/user_lists/store/edit/mutations'; import createState from '~/user_lists/store/edit/state'; -import { userList } from '../../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; describe('User List Edit Mutations', () => { let state; diff --git a/spec/frontend/user_lists/store/index/actions_spec.js b/spec/frontend/user_lists/store/index/actions_spec.js index c5d7d557de9..4a8d0afb963 100644 --- a/spec/frontend/user_lists/store/index/actions_spec.js +++ b/spec/frontend/user_lists/store/index/actions_spec.js @@ -12,7 +12,7 @@ import { } from '~/user_lists/store/index/actions'; import * as types from '~/user_lists/store/index/mutation_types'; import createState from '~/user_lists/store/index/state'; -import { userList } from '../../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('~/api.js'); @@ -24,14 +24,13 @@ describe('~/user_lists/store/index/actions', () => { }); describe('setUserListsOptions', () => { - it('should commit SET_USER_LISTS_OPTIONS mutation', (done) => { - testAction( + it('should commit SET_USER_LISTS_OPTIONS mutation', () => { + return testAction( setUserListsOptions, { page: '1', scope: 'all' }, state, [{ type: types.SET_USER_LISTS_OPTIONS, payload: { page: '1', scope: 'all' } }], [], - done, ); }); }); @@ -42,8 +41,8 @@ describe('~/user_lists/store/index/actions', () => { }); describe('success', () => { - it('dispatches requestUserLists and receiveUserListsSuccess ', (done) => { - testAction( + it('dispatches requestUserLists and receiveUserListsSuccess ', () => { + return testAction( fetchUserLists, null, state, @@ -57,16 +56,15 @@ describe('~/user_lists/store/index/actions', () => { type: 'receiveUserListsSuccess', }, ], - done, ); }); }); describe('error', () => { - it('dispatches requestUserLists and receiveUserListsError ', (done) => { + it('dispatches requestUserLists and receiveUserListsError ', () => { Api.fetchFeatureFlagUserLists.mockRejectedValue(); - testAction( + return testAction( fetchUserLists, null, state, @@ -79,21 +77,20 @@ describe('~/user_lists/store/index/actions', () => { type: 'receiveUserListsError', }, ], - done, ); }); }); }); describe('requestUserLists', () => { - it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', (done) => { - testAction(requestUserLists, null, state, [{ type: types.REQUEST_USER_LISTS }], [], done); + it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', () => { + return testAction(requestUserLists, null, state, [{ type: types.REQUEST_USER_LISTS }], []); }); }); describe('receiveUserListsSuccess', () => { - it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', () => { + return testAction( receiveUserListsSuccess, { data: [userList], headers: {} }, state, @@ -104,20 +101,18 @@ describe('~/user_lists/store/index/actions', () => { }, ], [], - done, ); }); }); describe('receiveUserListsError', () => { - it('should commit RECEIVE_USER_LISTS_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_USER_LISTS_ERROR mutation', () => { + return testAction( receiveUserListsError, null, state, [{ type: types.RECEIVE_USER_LISTS_ERROR }], [], - done, ); }); }); @@ -132,14 +127,13 @@ describe('~/user_lists/store/index/actions', () => { Api.deleteFeatureFlagUserList.mockResolvedValue(); }); - it('should refresh the user lists', (done) => { - testAction( + it('should refresh the user lists', () => { + return testAction( deleteUserList, userList, state, [], [{ type: 'requestDeleteUserList', payload: userList }, { type: 'fetchUserLists' }], - done, ); }); }); @@ -149,8 +143,8 @@ describe('~/user_lists/store/index/actions', () => { Api.deleteFeatureFlagUserList.mockRejectedValue({ response: { data: 'some error' } }); }); - it('should dispatch receiveDeleteUserListError', (done) => { - testAction( + it('should dispatch receiveDeleteUserListError', () => { + return testAction( deleteUserList, userList, state, @@ -162,15 +156,14 @@ describe('~/user_lists/store/index/actions', () => { payload: { list: userList, error: 'some error' }, }, ], - done, ); }); }); }); describe('receiveDeleteUserListError', () => { - it('should commit RECEIVE_DELETE_USER_LIST_ERROR with the given list', (done) => { - testAction( + it('should commit RECEIVE_DELETE_USER_LIST_ERROR with the given list', () => { + return testAction( receiveDeleteUserListError, { list: userList, error: 'mock error' }, state, @@ -181,22 +174,20 @@ describe('~/user_lists/store/index/actions', () => { }, ], [], - done, ); }); }); describe('clearAlert', () => { - it('should commit RECEIVE_CLEAR_ALERT', (done) => { + it('should commit RECEIVE_CLEAR_ALERT', () => { const alertIndex = 3; - testAction( + return testAction( clearAlert, alertIndex, state, [{ type: 'RECEIVE_CLEAR_ALERT', payload: alertIndex }], [], - done, ); }); }); diff --git a/spec/frontend/user_lists/store/index/mutations_spec.js b/spec/frontend/user_lists/store/index/mutations_spec.js index 370838ae5fb..18d6a9b8f38 100644 --- a/spec/frontend/user_lists/store/index/mutations_spec.js +++ b/spec/frontend/user_lists/store/index/mutations_spec.js @@ -2,7 +2,7 @@ import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import * as types from '~/user_lists/store/index/mutation_types'; import mutations from '~/user_lists/store/index/mutations'; import createState from '~/user_lists/store/index/state'; -import { userList } from '../../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; describe('~/user_lists/store/index/mutations', () => { let state; diff --git a/spec/frontend/user_lists/store/new/actions_spec.js b/spec/frontend/user_lists/store/new/actions_spec.js index 916ec2e6da7..fa69fa7fa66 100644 --- a/spec/frontend/user_lists/store/new/actions_spec.js +++ b/spec/frontend/user_lists/store/new/actions_spec.js @@ -4,7 +4,7 @@ import { redirectTo } from '~/lib/utils/url_utility'; import * as actions from '~/user_lists/store/new/actions'; import * as types from '~/user_lists/store/new/mutation_types'; import createState from '~/user_lists/store/new/state'; -import { userList } from '../../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('~/api'); jest.mock('~/lib/utils/url_utility'); diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js index 36850e623c7..4985417ad99 100644 --- a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js +++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js @@ -1,3 +1,4 @@ +import { nextTick } from 'vue'; import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import createFlash from '~/flash'; @@ -28,11 +29,6 @@ const testApprovals = () => ({ }); const testApprovalRulesResponse = () => ({ rules: [{ id: 2 }] }); -// For some reason, the `Promise.resolve()` needs to be deferred -// or the timing doesn't work. -const tick = () => Promise.resolve(); -const waitForTick = (done) => tick().then(done).catch(done.fail); - describe('MRWidget approvals', () => { let wrapper; let service; @@ -105,7 +101,7 @@ describe('MRWidget approvals', () => { // eslint-disable-next-line no-restricted-syntax wrapper.setData({ fetchingApprovals: true }); - return tick().then(() => { + return nextTick().then(() => { expect(wrapper.text()).toContain(FETCH_LOADING); }); }); @@ -116,10 +112,10 @@ describe('MRWidget approvals', () => { }); describe('when fetch approvals error', () => { - beforeEach((done) => { + beforeEach(() => { jest.spyOn(service, 'fetchApprovals').mockReturnValue(Promise.reject()); createComponent(); - waitForTick(done); + return nextTick(); }); it('still shows loading message', () => { @@ -133,13 +129,13 @@ describe('MRWidget approvals', () => { describe('action button', () => { describe('when mr is closed', () => { - beforeEach((done) => { + beforeEach(() => { mr.isOpen = false; mr.approvals.user_has_approved = false; mr.approvals.user_can_approve = true; createComponent(); - waitForTick(done); + return nextTick(); }); it('action is not rendered', () => { @@ -148,12 +144,12 @@ describe('MRWidget approvals', () => { }); describe('when user cannot approve', () => { - beforeEach((done) => { + beforeEach(() => { mr.approvals.user_has_approved = false; mr.approvals.user_can_approve = false; createComponent(); - waitForTick(done); + return nextTick(); }); it('action is not rendered', () => { @@ -168,9 +164,9 @@ describe('MRWidget approvals', () => { }); describe('and MR is unapproved', () => { - beforeEach((done) => { + beforeEach(() => { createComponent(); - waitForTick(done); + return nextTick(); }); it('approve action is rendered', () => { @@ -188,10 +184,10 @@ describe('MRWidget approvals', () => { }); describe('with no approvers', () => { - beforeEach((done) => { + beforeEach(() => { mr.approvals.approved_by = []; createComponent(); - waitForTick(done); + return nextTick(); }); it('approve action (with inverted style) is rendered', () => { @@ -204,10 +200,10 @@ describe('MRWidget approvals', () => { }); describe('with approvers', () => { - beforeEach((done) => { + beforeEach(() => { mr.approvals.approved_by = [{ user: { id: 7 } }]; createComponent(); - waitForTick(done); + return nextTick(); }); it('approve additionally action is rendered', () => { @@ -221,9 +217,9 @@ describe('MRWidget approvals', () => { }); describe('when approve action is clicked', () => { - beforeEach((done) => { + beforeEach(() => { createComponent(); - waitForTick(done); + return nextTick(); }); it('shows loading icon', () => { @@ -234,15 +230,15 @@ describe('MRWidget approvals', () => { action.vm.$emit('click'); - return tick().then(() => { + return nextTick().then(() => { expect(action.props('loading')).toBe(true); }); }); describe('and after loading', () => { - beforeEach((done) => { + beforeEach(() => { findAction().vm.$emit('click'); - waitForTick(done); + return nextTick(); }); it('calls service approve', () => { @@ -259,10 +255,10 @@ describe('MRWidget approvals', () => { }); describe('and error', () => { - beforeEach((done) => { + beforeEach(() => { jest.spyOn(service, 'approveMergeRequest').mockReturnValue(Promise.reject()); findAction().vm.$emit('click'); - waitForTick(done); + return nextTick(); }); it('flashes error message', () => { @@ -273,12 +269,12 @@ describe('MRWidget approvals', () => { }); describe('when user has approved', () => { - beforeEach((done) => { + beforeEach(() => { mr.approvals.user_has_approved = true; mr.approvals.user_can_approve = false; createComponent(); - waitForTick(done); + return nextTick(); }); it('revoke action is rendered', () => { @@ -291,9 +287,9 @@ describe('MRWidget approvals', () => { describe('when revoke action is clicked', () => { describe('and successful', () => { - beforeEach((done) => { + beforeEach(() => { findAction().vm.$emit('click'); - waitForTick(done); + return nextTick(); }); it('calls service unapprove', () => { @@ -310,10 +306,10 @@ describe('MRWidget approvals', () => { }); describe('and error', () => { - beforeEach((done) => { + beforeEach(() => { jest.spyOn(service, 'unapproveMergeRequest').mockReturnValue(Promise.reject()); findAction().vm.$emit('click'); - waitForTick(done); + return nextTick(); }); it('flashes error message', () => { @@ -333,11 +329,11 @@ describe('MRWidget approvals', () => { }); describe('and can approve', () => { - beforeEach((done) => { + beforeEach(() => { mr.approvals.user_can_approve = true; createComponent(); - waitForTick(done); + return nextTick(); }); it('is shown', () => { @@ -350,11 +346,11 @@ describe('MRWidget approvals', () => { }); describe('and cannot approve', () => { - beforeEach((done) => { + beforeEach(() => { mr.approvals.user_can_approve = false; createComponent(); - waitForTick(done); + return nextTick(); }); it('is shown', () => { @@ -369,9 +365,9 @@ describe('MRWidget approvals', () => { }); describe('approvals summary', () => { - beforeEach((done) => { + beforeEach(() => { createComponent(); - waitForTick(done); + return nextTick(); }); it('is rendered with props', () => { diff --git a/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js b/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js index 64e802c4fa5..98cfc04eb25 100644 --- a/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js +++ b/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js @@ -8,7 +8,7 @@ describe('generateText', () => { ${'%{danger_start}Hello world%{danger_end}'} | ${'<span class="gl-font-weight-bold gl-text-red-500">Hello world</span>'} ${'%{critical_start}Hello world%{critical_end}'} | ${'<span class="gl-font-weight-bold gl-text-red-800">Hello world</span>'} ${'%{same_start}Hello world%{same_end}'} | ${'<span class="gl-font-weight-bold gl-text-gray-700">Hello world</span>'} - ${'%{small_start}Hello world%{small_end}'} | ${'<span class="gl-font-sm">Hello world</span>'} + ${'%{small_start}Hello world%{small_end}'} | ${'<span class="gl-font-sm gl-text-gray-700">Hello world</span>'} ${'%{strong_start}%{danger_start}Hello world%{danger_end}%{strong_end}'} | ${'<span class="gl-font-weight-bold"><span class="gl-font-weight-bold gl-text-red-500">Hello world</span></span>'} ${'%{no_exist_start}Hello world%{no_exist_end}'} | ${'Hello world'} ${['array']} | ${null} diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_memory_usage_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_memory_usage_spec.js index c0a30a5093d..f0106914674 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_memory_usage_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_memory_usage_spec.js @@ -175,22 +175,19 @@ describe('MemoryUsage', () => { expect(el.querySelector('.js-usage-info')).toBeDefined(); }); - it('should show loading metrics message while metrics are being loaded', (done) => { + it('should show loading metrics message while metrics are being loaded', async () => { vm.loadingMetrics = true; vm.hasMetrics = false; vm.loadFailed = false; - nextTick(() => { - expect(el.querySelector('.js-usage-info.usage-info-loading')).toBeDefined(); + await nextTick(); - expect(el.querySelector('.js-usage-info .usage-info-load-spinner')).toBeDefined(); - - expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadingMetrics); - done(); - }); + expect(el.querySelector('.js-usage-info.usage-info-loading')).toBeDefined(); + expect(el.querySelector('.js-usage-info .usage-info-load-spinner')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadingMetrics); }); - it('should show deployment memory usage when metrics are loaded', (done) => { + it('should show deployment memory usage when metrics are loaded', async () => { // ignore BoostrapVue warnings jest.spyOn(console, 'warn').mockImplementation(); @@ -199,37 +196,32 @@ describe('MemoryUsage', () => { vm.loadFailed = false; vm.memoryMetrics = metricsMockData.metrics.memory_values[0].values; - nextTick(() => { - expect(el.querySelector('.memory-graph-container')).toBeDefined(); - expect(el.querySelector('.js-usage-info').innerText).toContain(messages.hasMetrics); - done(); - }); + await nextTick(); + + expect(el.querySelector('.memory-graph-container')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.hasMetrics); }); - it('should show failure message when metrics loading failed', (done) => { + it('should show failure message when metrics loading failed', async () => { vm.loadingMetrics = false; vm.hasMetrics = false; vm.loadFailed = true; - nextTick(() => { - expect(el.querySelector('.js-usage-info.usage-info-failed')).toBeDefined(); + await nextTick(); - expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadFailed); - done(); - }); + expect(el.querySelector('.js-usage-info.usage-info-failed')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadFailed); }); - it('should show metrics unavailable message when metrics loading failed', (done) => { + it('should show metrics unavailable message when metrics loading failed', async () => { vm.loadingMetrics = false; vm.hasMetrics = false; vm.loadFailed = false; - nextTick(() => { - expect(el.querySelector('.js-usage-info.usage-info-unavailable')).toBeDefined(); + await nextTick(); - expect(el.querySelector('.js-usage-info').innerText).toContain(messages.metricsUnavailable); - done(); - }); + expect(el.querySelector('.js-usage-info.usage-info-unavailable')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.metricsUnavailable); }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js index 7d86e453bc7..8efc4d84624 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js @@ -198,14 +198,13 @@ describe('MRWidgetMerged', () => { ); }); - it('hides button to copy commit SHA if SHA does not exist', (done) => { + it('hides button to copy commit SHA if SHA does not exist', async () => { vm.mr.mergeCommitSha = null; - nextTick(() => { - expect(selectors.copyMergeShaButton).toBe(null); - expect(vm.$el.querySelector('.mr-info-list').innerText).not.toContain('with'); - done(); - }); + await nextTick(); + + expect(selectors.copyMergeShaButton).toBe(null); + expect(vm.$el.querySelector('.mr-info-list').innerText).not.toContain('with'); }); it('shows merge commit SHA link', () => { @@ -214,24 +213,22 @@ describe('MRWidgetMerged', () => { expect(selectors.mergeCommitShaLink.href).toBe(vm.mr.mergeCommitPath); }); - it('should not show source branch deleted text', (done) => { + it('should not show source branch deleted text', async () => { vm.mr.sourceBranchRemoved = false; - nextTick(() => { - expect(vm.$el.innerText).not.toContain('The source branch has been deleted'); - done(); - }); + await nextTick(); + + expect(vm.$el.innerText).not.toContain('The source branch has been deleted'); }); - it('should show source branch deleting text', (done) => { + it('should show source branch deleting text', async () => { vm.mr.isRemovingSourceBranch = true; vm.mr.sourceBranchRemoved = false; - nextTick(() => { - expect(vm.$el.innerText).toContain('The source branch is being deleted'); - expect(vm.$el.innerText).not.toContain('The source branch has been deleted'); - done(); - }); + await nextTick(); + + expect(vm.$el.innerText).toContain('The source branch is being deleted'); + expect(vm.$el.innerText).not.toContain('The source branch has been deleted'); }); it('should use mergedEvent mergedAt as tooltip title', () => { 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 new file mode 100644 index 00000000000..88b8e32bd5d --- /dev/null +++ b/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js @@ -0,0 +1,149 @@ +import { GlButton } from '@gitlab/ui'; +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'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { trimText } from 'helpers/text_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container'; +import { registerExtension } from '~/vue_merge_request_widget/components/extensions'; +import httpStatusCodes from '~/lib/utils/http_status'; + +import { failedReport } from 'jest/reports/mock_data/mock_data'; +import mixedResultsTestReports from 'jest/reports/mock_data/new_and_fixed_failures_report.json'; +import newErrorsTestReports from 'jest/reports/mock_data/new_errors_report.json'; +import newFailedTestReports from 'jest/reports/mock_data/new_failures_report.json'; +import successTestReports from 'jest/reports/mock_data/no_failures_report.json'; +import resolvedFailures from 'jest/reports/mock_data/resolved_failures.json'; + +const reportWithParsingErrors = failedReport; +reportWithParsingErrors.suites[0].suite_errors = { + head: 'JUnit XML parsing failed: 2:24: FATAL: attributes construct error', + base: 'JUnit data parsing failed: string not matched', +}; + +describe('Test report extension', () => { + let wrapper; + let mock; + + registerExtension(testReportExtension); + + const endpoint = '/root/repo/-/merge_requests/4/test_reports.json'; + + const mockApi = (statusCode, data = mixedResultsTestReports) => { + mock.onGet(endpoint).reply(statusCode, data); + }; + + const findToggleCollapsedButton = () => wrapper.findByTestId('toggle-button'); + const findTertiaryButton = () => wrapper.find(GlButton); + const findAllExtensionListItems = () => wrapper.findAllByTestId('extension-list-item'); + + const createComponent = () => { + wrapper = mountExtended(extensionsContainer, { + propsData: { + mr: { + testResultsPath: endpoint, + headBlobPath: 'head/blob/path', + pipeline: { path: 'pipeline/path' }, + }, + }, + }); + }; + + const createExpandedWidgetWithData = async (data = mixedResultsTestReports) => { + mockApi(httpStatusCodes.OK, data); + createComponent(); + await waitForPromises(); + findToggleCollapsedButton().trigger('click'); + await waitForPromises(); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + describe('summary', () => { + it('displays loading text', () => { + mockApi(httpStatusCodes.OK); + createComponent(); + + expect(wrapper.text()).toContain(i18n.loading); + }); + + it('displays failed loading text', async () => { + mockApi(httpStatusCodes.INTERNAL_SERVER_ERROR); + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toContain(i18n.error); + }); + + it.each` + description | mockData | expectedResult + ${'mixed test results'} | ${mixedResultsTestReports} | ${'Test summary: 2 failed and 2 fixed test results, 11 total tests'} + ${'unchanged test results'} | ${successTestReports} | ${'Test summary: no changed test results, 11 total tests'} + ${'tests with errors'} | ${newErrorsTestReports} | ${'Test summary: 2 errors, 11 total tests'} + ${'failed test results'} | ${newFailedTestReports} | ${'Test summary: 2 failed, 11 total tests'} + ${'resolved failures'} | ${resolvedFailures} | ${'Test summary: 4 fixed test results, 11 total tests'} + `('displays summary text for $description', async ({ mockData, expectedResult }) => { + mockApi(httpStatusCodes.OK, mockData); + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toContain(expectedResult); + }); + + it('displays a link to the full report', async () => { + mockApi(httpStatusCodes.OK); + createComponent(); + + await waitForPromises(); + + expect(findTertiaryButton().text()).toBe('Full report'); + expect(findTertiaryButton().attributes('href')).toBe('pipeline/path/test_report'); + }); + + it('shows an error when a suite has a parsing error', async () => { + mockApi(httpStatusCodes.OK, reportWithParsingErrors); + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toContain(i18n.error); + }); + }); + + describe('expanded data', () => { + it('displays summary for each suite', async () => { + await createExpandedWidgetWithData(); + + expect(trimText(findAllExtensionListItems().at(0).text())).toBe( + 'rspec:pg: 1 failed and 2 fixed test results, 8 total tests', + ); + expect(trimText(findAllExtensionListItems().at(1).text())).toBe( + 'java ant: 1 failed, 3 total tests', + ); + }); + + it('displays suite parsing errors', async () => { + await createExpandedWidgetWithData(reportWithParsingErrors); + + const suiteText = trimText(findAllExtensionListItems().at(0).text()); + + expect(suiteText).toContain( + 'Head report parsing error: JUnit XML parsing failed: 2:24: FATAL: attributes construct error', + ); + expect(suiteText).toContain( + 'Base report parsing error: JUnit data parsing failed: string not matched', + ); + }); + }); +}); 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 0540107ea5f..9719e81fe12 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -46,6 +46,8 @@ describe('MrWidgetOptions', () => { let mock; const COLLABORATION_MESSAGE = 'Members who can merge are allowed to add commits'; + const findExtensionToggleButton = () => + wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]'); beforeEach(() => { gl.mrWidgetData = { ...mockData }; @@ -187,9 +189,9 @@ describe('MrWidgetOptions', () => { }); describe('when merge request is opened', () => { - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.isOpen = true; - nextTick(done); + return nextTick(); }); it('should render collaboration status', () => { @@ -198,9 +200,9 @@ describe('MrWidgetOptions', () => { }); describe('when merge request is not opened', () => { - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.isOpen = false; - nextTick(done); + return nextTick(); }); it('should not render collaboration status', () => { @@ -215,9 +217,9 @@ describe('MrWidgetOptions', () => { }); describe('when merge request is opened', () => { - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.isOpen = true; - nextTick(done); + return nextTick(); }); it('should not render collaboration status', () => { @@ -229,11 +231,11 @@ describe('MrWidgetOptions', () => { describe('showMergePipelineForkWarning', () => { describe('when the source project and target project are the same', () => { - beforeEach((done) => { + beforeEach(() => { Vue.set(wrapper.vm.mr, 'mergePipelinesEnabled', true); Vue.set(wrapper.vm.mr, 'sourceProjectId', 1); Vue.set(wrapper.vm.mr, 'targetProjectId', 1); - nextTick(done); + return nextTick(); }); it('should be false', () => { @@ -242,11 +244,11 @@ describe('MrWidgetOptions', () => { }); describe('when merge pipelines are not enabled', () => { - beforeEach((done) => { + beforeEach(() => { Vue.set(wrapper.vm.mr, 'mergePipelinesEnabled', false); Vue.set(wrapper.vm.mr, 'sourceProjectId', 1); Vue.set(wrapper.vm.mr, 'targetProjectId', 2); - nextTick(done); + return nextTick(); }); it('should be false', () => { @@ -255,11 +257,11 @@ describe('MrWidgetOptions', () => { }); describe('when merge pipelines are enabled _and_ the source project and target project are different', () => { - beforeEach((done) => { + beforeEach(() => { Vue.set(wrapper.vm.mr, 'mergePipelinesEnabled', true); Vue.set(wrapper.vm.mr, 'sourceProjectId', 1); Vue.set(wrapper.vm.mr, 'targetProjectId', 2); - nextTick(done); + return nextTick(); }); it('should be true', () => { @@ -439,15 +441,10 @@ describe('MrWidgetOptions', () => { expect(setFaviconOverlay).toHaveBeenCalledWith(overlayDataUrl); }); - it('should not call setFavicon when there is no ciStatusFaviconPath', (done) => { + it('should not call setFavicon when there is no ciStatusFaviconPath', async () => { wrapper.vm.mr.ciStatusFaviconPath = null; - wrapper.vm - .setFaviconHelper() - .then(() => { - expect(faviconElement.getAttribute('href')).toEqual(null); - done(); - }) - .catch(done.fail); + await wrapper.vm.setFaviconHelper(); + expect(faviconElement.getAttribute('href')).toEqual(null); }); }); @@ -534,44 +531,36 @@ describe('MrWidgetOptions', () => { expect(wrapper.find('.close-related-link').exists()).toBe(true); }); - it('does not render if state is nothingToMerge', (done) => { + it('does not render if state is nothingToMerge', async () => { wrapper.vm.mr.state = stateKey.nothingToMerge; - nextTick(() => { - expect(wrapper.find('.close-related-link').exists()).toBe(false); - done(); - }); + await nextTick(); + expect(wrapper.find('.close-related-link').exists()).toBe(false); }); }); describe('rendering source branch removal status', () => { - it('renders when user cannot remove branch and branch should be removed', (done) => { + it('renders when user cannot remove branch and branch should be removed', async () => { wrapper.vm.mr.canRemoveSourceBranch = false; wrapper.vm.mr.shouldRemoveSourceBranch = true; wrapper.vm.mr.state = 'readyToMerge'; - nextTick(() => { - const tooltip = wrapper.find('[data-testid="question-o-icon"]'); - - expect(wrapper.text()).toContain('Deletes the source branch'); - expect(tooltip.attributes('title')).toBe( - 'A user with write access to the source branch selected this option', - ); + await nextTick(); + const tooltip = wrapper.find('[data-testid="question-o-icon"]'); - done(); - }); + expect(wrapper.text()).toContain('Deletes the source branch'); + expect(tooltip.attributes('title')).toBe( + 'A user with write access to the source branch selected this option', + ); }); - it('does not render in merged state', (done) => { + it('does not render in merged state', async () => { wrapper.vm.mr.canRemoveSourceBranch = false; wrapper.vm.mr.shouldRemoveSourceBranch = true; wrapper.vm.mr.state = 'merged'; - nextTick(() => { - expect(wrapper.text()).toContain('The source branch has been deleted'); - expect(wrapper.text()).not.toContain('Deletes the source branch'); - - done(); - }); + await nextTick(); + expect(wrapper.text()).toContain('The source branch has been deleted'); + expect(wrapper.text()).not.toContain('Deletes the source branch'); }); }); @@ -605,7 +594,7 @@ describe('MrWidgetOptions', () => { status: SUCCESS, }; - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.deployments.push( { ...deploymentMockData, @@ -616,7 +605,7 @@ describe('MrWidgetOptions', () => { }, ); - nextTick(done); + return nextTick(); }); it('renders multiple deployments', () => { @@ -639,7 +628,7 @@ describe('MrWidgetOptions', () => { describe('pipeline for target branch after merge', () => { describe('with information for target branch pipeline', () => { - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.state = 'merged'; wrapper.vm.mr.mergePipeline = { id: 127, @@ -747,7 +736,7 @@ describe('MrWidgetOptions', () => { }, cancel_path: '/root/ci-web-terminal/pipelines/127/cancel', }; - nextTick(done); + return nextTick(); }); it('renders pipeline block', () => { @@ -755,7 +744,7 @@ describe('MrWidgetOptions', () => { }); describe('with post merge deployments', () => { - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.postMergeDeployments = [ { id: 15, @@ -787,7 +776,7 @@ describe('MrWidgetOptions', () => { }, ]; - nextTick(done); + return nextTick(); }); it('renders post deployment information', () => { @@ -797,10 +786,10 @@ describe('MrWidgetOptions', () => { }); describe('without information for target branch pipeline', () => { - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.state = 'merged'; - nextTick(done); + return nextTick(); }); it('does not render pipeline block', () => { @@ -809,10 +798,10 @@ describe('MrWidgetOptions', () => { }); describe('when state is not merged', () => { - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.state = 'archived'; - nextTick(done); + return nextTick(); }); it('does not render pipeline block', () => { @@ -905,7 +894,7 @@ describe('MrWidgetOptions', () => { beforeEach(() => { pollRequest = jest.spyOn(Poll.prototype, 'makeRequest'); - registerExtension(workingExtension); + registerExtension(workingExtension()); createComponent(); }); @@ -937,9 +926,7 @@ describe('MrWidgetOptions', () => { it('renders full data', async () => { await waitForPromises(); - wrapper - .find('[data-testid="widget-extension"] [data-testid="toggle-button"]') - .trigger('click'); + findExtensionToggleButton().trigger('click'); await nextTick(); @@ -975,6 +962,24 @@ describe('MrWidgetOptions', () => { }); }); + describe('expansion', () => { + it('hides collapse button', async () => { + registerExtension(workingExtension(false)); + createComponent(); + await waitForPromises(); + + expect(findExtensionToggleButton().exists()).toBe(false); + }); + + it('shows collapse button', async () => { + registerExtension(workingExtension(true)); + createComponent(); + await waitForPromises(); + + expect(findExtensionToggleButton().exists()).toBe(true); + }); + }); + describe('mock polling extension', () => { let pollRequest; let pollStop; @@ -1025,7 +1030,7 @@ describe('MrWidgetOptions', () => { it('captures sentry error and displays error when poll has failed', () => { expect(captureException).toHaveBeenCalledTimes(1); expect(captureException).toHaveBeenCalledWith(new Error('Fetch error')); - expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('error'); + expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed'); }); }); }); @@ -1036,7 +1041,7 @@ describe('MrWidgetOptions', () => { const itHandlesTheException = () => { expect(captureException).toHaveBeenCalledTimes(1); expect(captureException).toHaveBeenCalledWith(new Error('Fetch error')); - expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('error'); + expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed'); }; beforeEach(() => { diff --git a/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js b/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js index 9423fa17c44..22562bb4ddb 100644 --- a/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js +++ b/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js @@ -22,27 +22,25 @@ describe('Artifacts App Store Actions', () => { }); describe('setEndpoint', () => { - it('should commit SET_ENDPOINT mutation', (done) => { - testAction( + it('should commit SET_ENDPOINT mutation', () => { + return testAction( setEndpoint, 'endpoint.json', mockedState, [{ type: types.SET_ENDPOINT, payload: 'endpoint.json' }], [], - done, ); }); }); describe('requestArtifacts', () => { - it('should commit REQUEST_ARTIFACTS mutation', (done) => { - testAction( + it('should commit REQUEST_ARTIFACTS mutation', () => { + return testAction( requestArtifacts, null, mockedState, [{ type: types.REQUEST_ARTIFACTS }], [], - done, ); }); }); @@ -62,7 +60,7 @@ describe('Artifacts App Store Actions', () => { }); describe('success', () => { - it('dispatches requestArtifacts and receiveArtifactsSuccess ', (done) => { + it('dispatches requestArtifacts and receiveArtifactsSuccess ', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, [ { text: 'result.txt', @@ -72,7 +70,7 @@ describe('Artifacts App Store Actions', () => { }, ]); - testAction( + return testAction( fetchArtifacts, null, mockedState, @@ -96,7 +94,6 @@ describe('Artifacts App Store Actions', () => { type: 'receiveArtifactsSuccess', }, ], - done, ); }); }); @@ -106,8 +103,8 @@ describe('Artifacts App Store Actions', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500); }); - it('dispatches requestArtifacts and receiveArtifactsError ', (done) => { - testAction( + it('dispatches requestArtifacts and receiveArtifactsError ', () => { + return testAction( fetchArtifacts, null, mockedState, @@ -120,45 +117,41 @@ describe('Artifacts App Store Actions', () => { type: 'receiveArtifactsError', }, ], - done, ); }); }); }); describe('receiveArtifactsSuccess', () => { - it('should commit RECEIVE_ARTIFACTS_SUCCESS mutation with 200', (done) => { - testAction( + it('should commit RECEIVE_ARTIFACTS_SUCCESS mutation with 200', () => { + return testAction( receiveArtifactsSuccess, { data: { summary: {} }, status: 200 }, mockedState, [{ type: types.RECEIVE_ARTIFACTS_SUCCESS, payload: { summary: {} } }], [], - done, ); }); - it('should not commit RECEIVE_ARTIFACTS_SUCCESS mutation with 204', (done) => { - testAction( + it('should not commit RECEIVE_ARTIFACTS_SUCCESS mutation with 204', () => { + return testAction( receiveArtifactsSuccess, { data: { summary: {} }, status: 204 }, mockedState, [], [], - done, ); }); }); describe('receiveArtifactsError', () => { - it('should commit RECEIVE_ARTIFACTS_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_ARTIFACTS_ERROR mutation', () => { + return testAction( receiveArtifactsError, null, mockedState, [{ type: types.RECEIVE_ARTIFACTS_ERROR }], [], - done, ); }); }); diff --git a/spec/frontend/vue_mr_widget/test_extensions.js b/spec/frontend/vue_mr_widget/test_extensions.js index 986c1d6545a..6344636873f 100644 --- a/spec/frontend/vue_mr_widget/test_extensions.js +++ b/spec/frontend/vue_mr_widget/test_extensions.js @@ -1,6 +1,6 @@ import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants'; -export const workingExtension = { +export const workingExtension = (shouldCollapse = true) => ({ name: 'WidgetTestExtension', props: ['targetProjectFullPath'], expandEvent: 'test_expand_event', @@ -11,6 +11,9 @@ export const workingExtension = { statusIcon({ count }) { return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success; }, + shouldCollapse() { + return shouldCollapse; + }, }, methods: { fetchCollapsedData({ targetProjectFullPath }) { @@ -36,7 +39,7 @@ export const workingExtension = { ]); }, }, -}; +}); export const collapsedDataErrorExtension = { name: 'WidgetTestCollapsedErrorExtension', @@ -99,7 +102,7 @@ export const fullDataErrorExtension = { }; export const pollingExtension = { - ...workingExtension, + ...workingExtension(), enablePolling: true, }; diff --git a/spec/frontend/vue_shared/alert_details/alert_details_spec.js b/spec/frontend/vue_shared/alert_details/alert_details_spec.js index 7ee6e29e6de..7aa54a1c55a 100644 --- a/spec/frontend/vue_shared/alert_details/alert_details_spec.js +++ b/spec/frontend/vue_shared/alert_details/alert_details_spec.js @@ -12,12 +12,17 @@ import AlertSummaryRow from '~/vue_shared/alert_details/components/alert_summary import { PAGE_CONFIG, SEVERITY_LEVELS } from '~/vue_shared/alert_details/constants'; import createIssueMutation from '~/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; +import MetricImagesTab from '~/vue_shared/components/metric_images/metric_images_tab.vue'; +import createStore from '~/vue_shared/components/metric_images/store/'; +import service from '~/vue_shared/alert_details/service'; import mockAlerts from './mocks/alerts.json'; const mockAlert = mockAlerts[0]; const environmentName = 'Production'; const environmentPath = '/fake/path'; +jest.mock('~/vue_shared/alert_details/service'); + describe('AlertDetails', () => { let environmentData = { name: environmentName, path: environmentPath }; let mock; @@ -67,9 +72,11 @@ describe('AlertDetails', () => { $route: { params: {} }, }, stubs: { - ...stubs, AlertSummaryRow, + 'metric-images-tab': true, + ...stubs, }, + store: createStore({}, service), }), ); } @@ -91,7 +98,7 @@ describe('AlertDetails', () => { const findEnvironmentName = () => wrapper.findByTestId('environmentName'); const findEnvironmentPath = () => wrapper.findByTestId('environmentPath'); const findDetailsTable = () => wrapper.findComponent(AlertDetailsTable); - const findMetricsTab = () => wrapper.findByTestId('metrics'); + const findMetricsTab = () => wrapper.findComponent(MetricImagesTab); describe('Alert details', () => { describe('when alert is null', () => { @@ -129,8 +136,21 @@ describe('AlertDetails', () => { expect(wrapper.findByTestId('startTimeItem').exists()).toBe(true); expect(wrapper.findByTestId('startTimeItem').props('time')).toBe(mockAlert.startedAt); }); + }); + + describe('Metrics tab', () => { + it('should mount without errors', () => { + mountComponent({ + mountMethod: mount, + provide: { + canUpdate: true, + iid: '1', + }, + stubs: { + MetricImagesTab, + }, + }); - it('renders the metrics tab', () => { expect(findMetricsTab().exists()).toBe(true); }); }); @@ -312,7 +332,9 @@ describe('AlertDetails', () => { describe('header', () => { const findHeader = () => wrapper.findByTestId('alert-header'); - const stubs = { TimeAgoTooltip: { template: '<span>now</span>' } }; + const stubs = { + TimeAgoTooltip: { template: '<span>now</span>' }, + }; describe('individual header fields', () => { describe.each` diff --git a/spec/frontend/vue_shared/alert_details/service_spec.js b/spec/frontend/vue_shared/alert_details/service_spec.js new file mode 100644 index 00000000000..790854d0ca7 --- /dev/null +++ b/spec/frontend/vue_shared/alert_details/service_spec.js @@ -0,0 +1,44 @@ +import { fileList, fileListRaw } from 'jest/vue_shared/components/metric_images/mock_data'; +import { + getMetricImages, + uploadMetricImage, + updateMetricImage, + deleteMetricImage, +} from '~/vue_shared/alert_details/service'; +import * as alertManagementAlertsApi from '~/api/alert_management_alerts_api'; + +jest.mock('~/api/alert_management_alerts_api'); + +describe('Alert details service', () => { + it('fetches metric images', async () => { + alertManagementAlertsApi.fetchAlertMetricImages.mockResolvedValue({ data: fileListRaw }); + const result = await getMetricImages(); + + expect(alertManagementAlertsApi.fetchAlertMetricImages).toHaveBeenCalled(); + expect(result).toEqual(fileList); + }); + + it('uploads a metric image', async () => { + alertManagementAlertsApi.uploadAlertMetricImage.mockResolvedValue({ data: fileListRaw[0] }); + const result = await uploadMetricImage(); + + expect(alertManagementAlertsApi.uploadAlertMetricImage).toHaveBeenCalled(); + expect(result).toEqual(fileList[0]); + }); + + it('updates a metric image', async () => { + alertManagementAlertsApi.updateAlertMetricImage.mockResolvedValue({ data: fileListRaw[0] }); + const result = await updateMetricImage(); + + expect(alertManagementAlertsApi.updateAlertMetricImage).toHaveBeenCalled(); + expect(result).toEqual(fileList[0]); + }); + + it('deletes a metric image', async () => { + alertManagementAlertsApi.deleteAlertMetricImage.mockResolvedValue({ data: '' }); + const result = await deleteMetricImage(); + + expect(alertManagementAlertsApi.deleteAlertMetricImage).toHaveBeenCalled(); + expect(result).toEqual({}); + }); +}); diff --git a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap index c14cf0db370..bdf5ea23812 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap @@ -218,65 +218,88 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <div class="award-menu-holder gl-my-2" > - <button - aria-label="Add reaction" - class="btn add-reaction-button js-add-award btn-default btn-md gl-button js-test-add-button-class" + <div + class="emoji-picker" + data-testid="emoji-picker" title="Add reaction" - type="button" > - <!----> - - <!----> - - <span - class="gl-button-text" + <div + boundary="scrollParent" + class="dropdown b-dropdown gl-new-dropdown btn-group" + id="__BVID__13" + lazy="" + menu-class="dropdown-extended-height" + no-flip="" > - <span - class="reaction-control-icon reaction-control-icon-neutral" + <!----> + <button + aria-expanded="false" + aria-haspopup="true" + class="btn dropdown-toggle btn-default btn-md add-reaction-button btn-icon gl-relative! gl-button gl-dropdown-toggle btn-default-secondary" + id="__BVID__13__BV_toggle_" + type="button" > - <svg - aria-hidden="true" - class="gl-icon s16" - data-testid="slight-smile-icon" - role="img" + <span + class="gl-sr-only" > - <use - href="#slight-smile" - /> - </svg> - </span> - - <span - class="reaction-control-icon reaction-control-icon-positive" - > - <svg - aria-hidden="true" - class="gl-icon s16" - data-testid="smiley-icon" - role="img" + Add reaction + </span> + + <span + class="reaction-control-icon reaction-control-icon-neutral" > - <use - href="#smiley" - /> - </svg> - </span> - - <span - class="reaction-control-icon reaction-control-icon-super-positive" - > - <svg - aria-hidden="true" - class="gl-icon s16" - data-testid="smile-icon" - role="img" + <svg + aria-hidden="true" + class="gl-icon s16" + data-testid="slight-smile-icon" + role="img" + > + <use + href="#slight-smile" + /> + </svg> + </span> + + <span + class="reaction-control-icon reaction-control-icon-positive" > - <use - href="#smile" - /> - </svg> - </span> - </span> - </button> + <svg + aria-hidden="true" + class="gl-icon s16" + data-testid="smiley-icon" + role="img" + > + <use + href="#smiley" + /> + </svg> + </span> + + <span + class="reaction-control-icon reaction-control-icon-super-positive" + > + <svg + aria-hidden="true" + class="gl-icon s16" + data-testid="smile-icon" + role="img" + > + <use + href="#smile" + /> + </svg> + </span> + </button> + <ul + aria-labelledby="__BVID__13__BV_toggle_" + class="dropdown-menu dropdown-extended-height dropdown-menu-right" + role="menu" + tabindex="-1" + > + <!----> + </ul> + </div> + </div> </div> </div> `; diff --git a/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap deleted file mode 100644 index 1d8e04b83a3..00000000000 --- a/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap +++ /dev/null @@ -1,21 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Identicon entity id is a GraphQL id matches snapshot 1`] = ` -<div - class="avatar identicon s40 bg2" -> - - E - -</div> -`; - -exports[`Identicon entity id is a number matches snapshot 1`] = ` -<div - class="avatar identicon s40 bg2" -> - - E - -</div> -`; diff --git a/spec/frontend/vue_shared/components/awards_list_spec.js b/spec/frontend/vue_shared/components/awards_list_spec.js index 95e9760c181..1c8cf726aca 100644 --- a/spec/frontend/vue_shared/components/awards_list_spec.js +++ b/spec/frontend/vue_shared/components/awards_list_spec.js @@ -76,7 +76,7 @@ describe('vue_shared/components/awards_list', () => { count: Number(x.find('.js-counter').text()), }; }); - const findAddAwardButton = () => wrapper.find('.js-add-award'); + const findAddAwardButton = () => wrapper.find('[data-testid="emoji-picker"]'); describe('default', () => { beforeEach(() => { @@ -151,7 +151,6 @@ describe('vue_shared/components/awards_list', () => { const btn = findAddAwardButton(); expect(btn.exists()).toBe(true); - expect(btn.classes(TEST_ADD_BUTTON_CLASS)).toBe(true); }); }); diff --git a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js index 663ebd3e12f..4b44311b253 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js +++ b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js @@ -2,9 +2,6 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { HIGHLIGHT_CLASS_NAME } from '~/vue_shared/components/blob_viewers/constants'; import SimpleViewer from '~/vue_shared/components/blob_viewers/simple_viewer.vue'; -import LineHighlighter from '~/blob/line_highlighter'; - -jest.mock('~/blob/line_highlighter'); describe('Blob Simple Viewer component', () => { let wrapper; @@ -30,20 +27,6 @@ describe('Blob Simple Viewer component', () => { wrapper.destroy(); }); - describe('refactorBlobViewer feature flag', () => { - it('loads the LineHighlighter if refactorBlobViewer is enabled', () => { - createComponent('', false, { refactorBlobViewer: true }); - - expect(LineHighlighter).toHaveBeenCalled(); - }); - - it('does not load the LineHighlighter if refactorBlobViewer is disabled', () => { - createComponent('', false, { refactorBlobViewer: false }); - - expect(LineHighlighter).not.toHaveBeenCalled(); - }); - }); - it('does not fail if content is empty', () => { const spy = jest.spyOn(window.console, 'error'); createComponent(''); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js index 575e8a73050..b6a181e6a0b 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js @@ -26,7 +26,6 @@ import { tokenValueMilestone, tokenValueMembership, tokenValueConfidential, - tokenValueEmpty, } from './mock_data'; jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({ @@ -207,33 +206,14 @@ describe('FilteredSearchBarRoot', () => { }); }); - describe('watchers', () => { - describe('filterValue', () => { - it('emits component event `onFilter` with empty array and false when filter was never selected', async () => { - wrapper = createComponent({ initialFilterValue: [tokenValueEmpty] }); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - initialRender: false, - filterValue: [tokenValueEmpty], - }); - - await nextTick(); - expect(wrapper.emitted('onFilter')[0]).toEqual([[], false]); - }); + describe('events', () => { + it('emits component event `onFilter` with empty array and true when initially selected filter value was cleared', async () => { + wrapper = createComponent({ initialFilterValue: [tokenValueLabel] }); - it('emits component event `onFilter` with empty array and true when initially selected filter value was cleared', async () => { - wrapper = createComponent({ initialFilterValue: [tokenValueLabel] }); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - initialRender: false, - filterValue: [tokenValueEmpty], - }); + wrapper.find(GlFilteredSearch).vm.$emit('clear'); - await nextTick(); - expect(wrapper.emitted('onFilter')[0]).toEqual([[], true]); - }); + await nextTick(); + expect(wrapper.emitted('onFilter')[0]).toEqual([[], true]); }); }); diff --git a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js index b67385cc43e..e636f58d868 100644 --- a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js +++ b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js @@ -89,8 +89,11 @@ describe('InputCopyToggleVisibility', () => { }); describe('when clicked', () => { + let event; + beforeEach(async () => { - await findRevealButton().trigger('click'); + event = { stopPropagation: jest.fn() }; + await findRevealButton().trigger('click', event); }); it('displays value', () => { @@ -110,6 +113,11 @@ describe('InputCopyToggleVisibility', () => { it('emits `visibility-change` event', () => { expect(wrapper.emitted('visibility-change')[0]).toEqual([true]); }); + + it('stops propagation on click event', () => { + // in case the input is located in a dropdown or modal + expect(event.stopPropagation).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/spec/frontend/vue_shared/components/help_popover_spec.js b/spec/frontend/vue_shared/components/help_popover_spec.js index 597fb63d95c..64dce194327 100644 --- a/spec/frontend/vue_shared/components/help_popover_spec.js +++ b/spec/frontend/vue_shared/components/help_popover_spec.js @@ -34,7 +34,7 @@ describe('HelpPopover', () => { it('renders a link button with an icon question', () => { expect(findQuestionButton().props()).toMatchObject({ - icon: 'question', + icon: 'question-o', variant: 'link', }); }); diff --git a/spec/frontend/vue_shared/components/identicon_spec.js b/spec/frontend/vue_shared/components/identicon_spec.js deleted file mode 100644 index 24fc3713e2b..00000000000 --- a/spec/frontend/vue_shared/components/identicon_spec.js +++ /dev/null @@ -1,50 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import IdenticonComponent from '~/vue_shared/components/identicon.vue'; - -describe('Identicon', () => { - let wrapper; - - const defaultProps = { - entityId: 1, - entityName: 'entity-name', - sizeClass: 's40', - }; - - const createComponent = (props = {}) => { - wrapper = shallowMount(IdenticonComponent, { - propsData: { - ...defaultProps, - ...props, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('entity id is a number', () => { - beforeEach(() => createComponent()); - - it('matches snapshot', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - - it('adds a correct class to identicon', () => { - expect(wrapper.find({ ref: 'identicon' }).classes()).toContain('bg2'); - }); - }); - - describe('entity id is a GraphQL id', () => { - beforeEach(() => createComponent({ entityId: 'gid://gitlab/Project/8' })); - - it('matches snapshot', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - - it('adds a correct class to identicon', () => { - expect(wrapper.find({ ref: 'identicon' }).classes()).toContain('bg2'); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/line_numbers_spec.js b/spec/frontend/vue_shared/components/line_numbers_spec.js deleted file mode 100644 index 38c26226863..00000000000 --- a/spec/frontend/vue_shared/components/line_numbers_spec.js +++ /dev/null @@ -1,37 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlIcon, GlLink } from '@gitlab/ui'; -import LineNumbers from '~/vue_shared/components/line_numbers.vue'; - -describe('Line Numbers component', () => { - let wrapper; - const lines = 10; - - const createComponent = () => { - wrapper = shallowMount(LineNumbers, { propsData: { lines } }); - }; - - const findGlIcon = () => wrapper.findComponent(GlIcon); - const findLineNumbers = () => wrapper.findAllComponents(GlLink); - const findFirstLineNumber = () => findLineNumbers().at(0); - - beforeEach(() => createComponent()); - - afterEach(() => wrapper.destroy()); - - describe('rendering', () => { - it('renders Line Numbers', () => { - expect(findLineNumbers().length).toBe(lines); - expect(findFirstLineNumber().attributes()).toMatchObject({ - id: 'L1', - to: '#LC1', - }); - }); - - it('renders a link icon', () => { - expect(findGlIcon().props()).toMatchObject({ - size: 12, - name: 'link', - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/local_storage_sync_spec.js b/spec/frontend/vue_shared/components/local_storage_sync_spec.js index dac633fe6c8..a80717a1aea 100644 --- a/spec/frontend/vue_shared/components/local_storage_sync_spec.js +++ b/spec/frontend/vue_shared/components/local_storage_sync_spec.js @@ -1,31 +1,29 @@ import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +const STORAGE_KEY = 'key'; + describe('Local Storage Sync', () => { let wrapper; - const createComponent = ({ props = {}, slots = {} } = {}) => { + const createComponent = ({ value, asString = false, slots = {} } = {}) => { wrapper = shallowMount(LocalStorageSync, { - propsData: props, + propsData: { storageKey: STORAGE_KEY, value, asString }, slots, }); }; + const setStorageValue = (value) => localStorage.setItem(STORAGE_KEY, value); + const getStorageValue = (value) => localStorage.getItem(STORAGE_KEY, value); + afterEach(() => { - if (wrapper) { - wrapper.destroy(); - } - wrapper = null; + wrapper.destroy(); localStorage.clear(); }); it('is a renderless component', () => { const html = '<div class="test-slot"></div>'; createComponent({ - props: { - storageKey: 'key', - }, slots: { default: html, }, @@ -35,233 +33,136 @@ describe('Local Storage Sync', () => { }); describe('localStorage empty', () => { - const storageKey = 'issue_list_order'; - it('does not emit input event', () => { - createComponent({ - props: { - storageKey, - value: 'ascending', - }, - }); - - expect(wrapper.emitted('input')).toBeFalsy(); - }); - - it.each('foo', 3, true, ['foo', 'bar'], { foo: 'bar' })( - 'saves updated value to localStorage', - async (newValue) => { - createComponent({ - props: { - storageKey, - value: 'initial', - }, - }); - - wrapper.setProps({ value: newValue }); + createComponent({ value: 'ascending' }); - await nextTick(); - expect(localStorage.getItem(storageKey)).toBe(String(newValue)); - }, - ); - - it('does not save default value', () => { - const value = 'ascending'; + expect(wrapper.emitted('input')).toBeUndefined(); + }); - createComponent({ - props: { - storageKey, - value, - }, - }); + it('does not save initial value if it did not change', () => { + createComponent({ value: 'ascending' }); - expect(localStorage.getItem(storageKey)).toBe(null); + expect(getStorageValue()).toBeNull(); }); }); describe('localStorage has saved value', () => { - const storageKey = 'issue_list_order_by'; const savedValue = 'last_updated'; beforeEach(() => { - localStorage.setItem(storageKey, savedValue); + setStorageValue(savedValue); + createComponent({ asString: true }); }); it('emits input event with saved value', () => { - createComponent({ - props: { - storageKey, - value: 'ascending', - }, - }); - expect(wrapper.emitted('input')[0][0]).toBe(savedValue); }); - it('does not overwrite localStorage with prop value', () => { - createComponent({ - props: { - storageKey, - value: 'created', - }, - }); - - expect(localStorage.getItem(storageKey)).toBe(savedValue); + it('does not overwrite localStorage with initial prop value', () => { + expect(getStorageValue()).toBe(savedValue); }); it('updating the value updates localStorage', async () => { - createComponent({ - props: { - storageKey, - value: 'created', - }, - }); - const newValue = 'last_updated'; - wrapper.setProps({ - value: newValue, - }); + await wrapper.setProps({ value: newValue }); - await nextTick(); - expect(localStorage.getItem(storageKey)).toBe(newValue); + expect(getStorageValue()).toBe(newValue); }); + }); + describe('persist prop', () => { it('persists the value by default', async () => { const persistedValue = 'persisted'; + createComponent({ asString: true }); + // Sanity check to make sure we start with nothing saved. + expect(getStorageValue()).toBeNull(); - createComponent({ - props: { - storageKey, - }, - }); + await wrapper.setProps({ value: persistedValue }); - wrapper.setProps({ value: persistedValue }); - await nextTick(); - expect(localStorage.getItem(storageKey)).toBe(persistedValue); + expect(getStorageValue()).toBe(persistedValue); }); it('does not save a value if persist is set to false', async () => { + const value = 'saved'; const notPersistedValue = 'notPersisted'; + createComponent({ asString: true }); + // Save some value so we can test that it's not overwritten. + await wrapper.setProps({ value }); - createComponent({ - props: { - storageKey, - }, - }); + expect(getStorageValue()).toBe(value); - wrapper.setProps({ persist: false, value: notPersistedValue }); - await nextTick(); - expect(localStorage.getItem(storageKey)).not.toBe(notPersistedValue); + await wrapper.setProps({ persist: false, value: notPersistedValue }); + + expect(getStorageValue()).toBe(value); }); }); - describe('with "asJson" prop set to "true"', () => { - const storageKey = 'testStorageKey'; - - describe.each` - value | serializedValue - ${null} | ${'null'} - ${''} | ${'""'} - ${true} | ${'true'} - ${false} | ${'false'} - ${42} | ${'42'} - ${'42'} | ${'"42"'} - ${'{ foo: '} | ${'"{ foo: "'} - ${['test']} | ${'["test"]'} - ${{ foo: 'bar' }} | ${'{"foo":"bar"}'} - `('given $value', ({ value, serializedValue }) => { - describe('is a new value', () => { - beforeEach(async () => { - createComponent({ - props: { - storageKey, - value: 'initial', - asJson: true, - }, - }); - - wrapper.setProps({ value }); - - await nextTick(); - }); - - it('serializes the value correctly to localStorage', () => { - expect(localStorage.getItem(storageKey)).toBe(serializedValue); - }); - }); - - describe('is already stored', () => { - beforeEach(() => { - localStorage.setItem(storageKey, serializedValue); - - createComponent({ - props: { - storageKey, - value: 'initial', - asJson: true, - }, - }); - }); - - it('emits an input event with the deserialized value', () => { - expect(wrapper.emitted('input')).toEqual([[value]]); - }); - }); + describe('saving and restoring', () => { + it.each` + value | asString + ${'foo'} | ${true} + ${'foo'} | ${false} + ${'{ a: 1 }'} | ${true} + ${'{ a: 1 }'} | ${false} + ${3} | ${false} + ${['foo', 'bar']} | ${false} + ${{ foo: 'bar' }} | ${false} + ${null} | ${false} + ${' '} | ${false} + ${true} | ${false} + ${false} | ${false} + ${42} | ${false} + ${'42'} | ${false} + ${'{ foo: '} | ${false} + `('saves and restores the same value', async ({ value, asString }) => { + // Create an initial component to save the value. + createComponent({ asString }); + await wrapper.setProps({ value }); + wrapper.destroy(); + // Create a second component to restore the value. Restore is only done once, when the + // component is first mounted. + createComponent({ asString }); + + expect(wrapper.emitted('input')[0][0]).toEqual(value); }); - describe('with bad JSON in storage', () => { - const badJSON = '{ badJSON'; - - beforeEach(() => { - jest.spyOn(console, 'warn').mockImplementation(); - localStorage.setItem(storageKey, badJSON); - - createComponent({ - props: { - storageKey, - value: 'initial', - asJson: true, - }, - }); - }); - - it('should console warn', () => { - // eslint-disable-next-line no-console - expect(console.warn).toHaveBeenCalledWith( - `[gitlab] Failed to deserialize value from localStorage (key=${storageKey})`, - badJSON, - ); - }); - - it('should not emit an input event', () => { - expect(wrapper.emitted('input')).toBeUndefined(); - }); + it('shows a warning when trying to save a non-string value when asString prop is true', async () => { + const spy = jest.spyOn(console, 'warn').mockImplementation(); + createComponent({ asString: true }); + await wrapper.setProps({ value: [] }); + + expect(spy).toHaveBeenCalled(); }); }); - it('clears localStorage when clear property is true', async () => { - const storageKey = 'key'; - const value = 'initial'; + describe('with bad JSON in storage', () => { + const badJSON = '{ badJSON'; + let spy; - createComponent({ - props: { - storageKey, - }, + beforeEach(() => { + spy = jest.spyOn(console, 'warn').mockImplementation(); + setStorageValue(badJSON); + createComponent(); }); - wrapper.setProps({ - value, + + it('should console warn', () => { + expect(spy).toHaveBeenCalled(); }); - await nextTick(); + it('should not emit an input event', () => { + expect(wrapper.emitted('input')).toBeUndefined(); + }); + }); - expect(localStorage.getItem(storageKey)).toBe(value); + it('clears localStorage when clear property is true', async () => { + const value = 'initial'; + createComponent({ asString: true }); + await wrapper.setProps({ value }); - wrapper.setProps({ - clear: true, - }); + expect(getStorageValue()).toBe(value); - await nextTick(); + await wrapper.setProps({ clear: true }); - expect(localStorage.getItem(storageKey)).toBe(null); + expect(getStorageValue()).toBeNull(); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js b/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js index c56628fcbcd..ecb2b37c3a5 100644 --- a/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js +++ b/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlFormTextarea, GlButton } from '@gitlab/ui'; +import { GlDropdown, GlFormTextarea, GlButton, GlAlert } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import ApplySuggestionComponent from '~/vue_shared/components/markdown/apply_suggestion.vue'; @@ -10,9 +10,10 @@ describe('Apply Suggestion component', () => { wrapper = shallowMount(ApplySuggestionComponent, { propsData: { ...propsData, ...props } }); }; - const findDropdown = () => wrapper.find(GlDropdown); - const findTextArea = () => wrapper.find(GlFormTextarea); - const findApplyButton = () => wrapper.find(GlButton); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findTextArea = () => wrapper.findComponent(GlFormTextarea); + const findApplyButton = () => wrapper.findComponent(GlButton); + const findAlert = () => wrapper.findComponent(GlAlert); beforeEach(() => createWrapper()); @@ -53,6 +54,20 @@ describe('Apply Suggestion component', () => { }); }); + describe('error', () => { + it('displays an error message', () => { + const errorMessage = 'Error message'; + createWrapper({ errorMessage }); + + const alert = findAlert(); + + expect(alert.exists()).toBe(true); + expect(alert.props('variant')).toBe('danger'); + expect(alert.props('dismissible')).toBe(false); + expect(alert.text()).toBe(errorMessage); + }); + }); + describe('apply suggestion', () => { it('emits an apply event with no message if no message was added', () => { findTextArea().vm.$emit('input', null); diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js index b5daa389fc6..d1c4d777d44 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -85,7 +85,7 @@ describe('Markdown field component', () => { describe('mounted', () => { const previewHTML = ` <p>markdown preview</p> - <video src="${FIXTURES_PATH}/static/mock-video.mp4" muted="muted"></video> + <video src="${FIXTURES_PATH}/static/mock-video.mp4"></video> `; let previewLink; let writeLink; @@ -101,6 +101,21 @@ describe('Markdown field component', () => { expect(subject.find('.zen-backdrop textarea').element).not.toBeNull(); }); + it('renders referenced commands on markdown preview', async () => { + axiosMock + .onPost(markdownPreviewPath) + .reply(200, { references: { users: [], commands: 'test command' } }); + + previewLink = getPreviewLink(); + previewLink.vm.$emit('click', { target: {} }); + + await axios.waitFor(markdownPreviewPath); + const referencedCommands = subject.find('[data-testid="referenced-commands"]'); + + expect(referencedCommands.exists()).toBe(true); + expect(referencedCommands.text()).toContain('test command'); + }); + describe('markdown preview', () => { beforeEach(() => { axiosMock.onPost(markdownPreviewPath).reply(200, { body: previewHTML }); diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js index 9ffb9c6a541..fa4ca63f910 100644 --- a/spec/frontend/vue_shared/components/markdown/header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/header_spec.js @@ -95,7 +95,7 @@ describe('Markdown field header component', () => { it('hides toolbar in preview mode', () => { createWrapper({ previewMarkdown: true }); - expect(findToolbar().classes().includes('gl-display-none')).toBe(true); + expect(findToolbar().classes().includes('gl-display-none!')).toBe(true); }); it('emits toggle markdown event when clicking preview tab', async () => { diff --git a/spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap b/spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap new file mode 100644 index 00000000000..5dd12d9edf5 --- /dev/null +++ b/spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Metrics upload item render the metrics image component 1`] = ` +<gl-card-stub + bodyclass="gl-border-1,gl-border-t-solid,gl-border-gray-100,[object Object]" + class="collapsible-card border gl-p-0 gl-mb-5" + footerclass="" + headerclass="gl-display-flex gl-align-items-center gl-border-b-0 gl-py-3" +> + <gl-modal-stub + actioncancel="[object Object]" + actionprimary="[object Object]" + body-class="gl-pb-0! gl-min-h-6!" + dismisslabel="Close" + modalclass="" + modalid="delete-metric-modal" + size="sm" + titletag="h4" + > + + <p> + Are you sure you wish to delete this image? + </p> + </gl-modal-stub> + + <gl-modal-stub + actioncancel="[object Object]" + actionprimary="[object Object]" + data-testid="metric-image-edit-modal" + dismisslabel="Close" + modalclass="" + modalid="edit-metric-modal" + size="sm" + titletag="h4" + > + + <gl-form-group-stub + label="Text (optional)" + label-for="upload-text-input" + labeldescription="" + optionaltext="(optional)" + > + <gl-form-input-stub + data-testid="metric-image-text-field" + id="upload-text-input" + /> + </gl-form-group-stub> + + <gl-form-group-stub + description="Must start with http or https" + label="Link (optional)" + label-for="upload-url-input" + labeldescription="" + optionaltext="(optional)" + > + <gl-form-input-stub + data-testid="metric-image-url-field" + id="upload-url-input" + /> + </gl-form-group-stub> + </gl-modal-stub> + + <div + class="gl-display-flex gl-flex-direction-column" + data-testid="metric-image-body" + > + <img + class="gl-max-w-full gl-align-self-center" + src="test_file_path" + /> + </div> +</gl-card-stub> +`; diff --git a/spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js b/spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js new file mode 100644 index 00000000000..2cefa77b72d --- /dev/null +++ b/spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js @@ -0,0 +1,174 @@ +import { GlFormInput, GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import merge from 'lodash/merge'; +import Vuex from 'vuex'; +import MetricImagesTable from '~/vue_shared/components/metric_images/metric_images_table.vue'; +import MetricImagesTab from '~/vue_shared/components/metric_images/metric_images_tab.vue'; +import createStore from '~/vue_shared/components/metric_images/store'; +import waitForPromises from 'helpers/wait_for_promises'; +import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; +import { fileList, initialData } from './mock_data'; + +const service = { + getMetricImages: jest.fn(), +}; + +const mockEvent = { preventDefault: jest.fn() }; + +Vue.use(Vuex); + +describe('Metric images tab', () => { + let wrapper; + let store; + + const mountComponent = (options = {}) => { + store = createStore({}, service); + + wrapper = shallowMount( + MetricImagesTab, + merge( + { + store, + provide: { + canUpdate: true, + iid: initialData.issueIid, + projectId: initialData.projectId, + }, + }, + options, + ), + ); + }; + + beforeEach(() => { + mountComponent(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + const findUploadDropzone = () => wrapper.findComponent(UploadDropzone); + const findImages = () => wrapper.findAllComponents(MetricImagesTable); + const findModal = () => wrapper.findComponent(GlModal); + const submitModal = () => findModal().vm.$emit('primary', mockEvent); + const cancelModal = () => findModal().vm.$emit('hidden'); + + describe('empty state', () => { + beforeEach(() => { + mountComponent(); + }); + + it('renders the upload component', () => { + expect(findUploadDropzone().exists()).toBe(true); + }); + }); + + describe('permissions', () => { + beforeEach(() => { + mountComponent({ provide: { canUpdate: false } }); + }); + + it('hides the upload component when disallowed', () => { + expect(findUploadDropzone().exists()).toBe(false); + }); + }); + + describe('onLoad action', () => { + it('should load images', async () => { + service.getMetricImages.mockImplementation(() => Promise.resolve(fileList)); + + mountComponent(); + + await waitForPromises(); + + expect(findImages().length).toBe(1); + }); + }); + + describe('add metric dialog', () => { + const testUrl = 'test url'; + + it('should open the add metric dialog when clicked', async () => { + mountComponent(); + + findUploadDropzone().vm.$emit('change'); + + await waitForPromises(); + + expect(findModal().attributes('visible')).toBe('true'); + }); + + it('should close when cancelled', async () => { + mountComponent({ + data() { + return { modalVisible: true }; + }, + }); + + cancelModal(); + + await waitForPromises(); + + expect(findModal().attributes('visible')).toBeFalsy(); + }); + + it('should add files and url when selected', async () => { + mountComponent({ + data() { + return { modalVisible: true, modalUrl: testUrl, currentFiles: fileList }; + }, + }); + + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + submitModal(); + + await waitForPromises(); + + expect(dispatchSpy).toHaveBeenCalledWith('uploadImage', { + files: fileList, + url: testUrl, + urlText: '', + }); + }); + + describe('url field', () => { + beforeEach(() => { + mountComponent({ + data() { + return { modalVisible: true, modalUrl: testUrl }; + }, + }); + }); + + it('should display the url field', () => { + expect(wrapper.find('#upload-url-input').attributes('value')).toBe(testUrl); + }); + + it('should display the url text field', () => { + expect(wrapper.find('#upload-text-input').attributes('value')).toBe(''); + }); + + it('should clear url when cancelled', async () => { + cancelModal(); + + await waitForPromises(); + + expect(wrapper.findComponent(GlFormInput).attributes('value')).toBe(''); + }); + + it('should clear url when submitted', async () => { + submitModal(); + + await waitForPromises(); + + expect(wrapper.findComponent(GlFormInput).attributes('value')).toBe(''); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js b/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js new file mode 100644 index 00000000000..d792bd46ccd --- /dev/null +++ b/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js @@ -0,0 +1,230 @@ +import { GlLink, GlModal } from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; +import Vue from 'vue'; +import merge from 'lodash/merge'; +import Vuex from 'vuex'; +import createStore from '~/vue_shared/components/metric_images/store'; +import MetricsImageTable from '~/vue_shared/components/metric_images/metric_images_table.vue'; +import waitForPromises from 'helpers/wait_for_promises'; + +const defaultProps = { + id: 1, + filePath: 'test_file_path', + filename: 'test_file_name', +}; + +const mockEvent = { preventDefault: jest.fn() }; + +Vue.use(Vuex); + +describe('Metrics upload item', () => { + let wrapper; + let store; + + const mountComponent = (options = {}, mountMethod = mount) => { + store = createStore(); + + wrapper = mountMethod( + MetricsImageTable, + merge( + { + store, + propsData: { + ...defaultProps, + }, + provide: { canUpdate: true }, + }, + options, + ), + ); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + const findImageLink = () => wrapper.findComponent(GlLink); + const findLabelTextSpan = () => wrapper.find('[data-testid="metric-image-label-span"]'); + const findCollapseButton = () => wrapper.find('[data-testid="collapse-button"]'); + const findMetricImageBody = () => wrapper.find('[data-testid="metric-image-body"]'); + const findModal = () => wrapper.findComponent(GlModal); + const findEditModal = () => wrapper.find('[data-testid="metric-image-edit-modal"]'); + const findDeleteButton = () => wrapper.find('[data-testid="delete-button"]'); + const findEditButton = () => wrapper.find('[data-testid="edit-button"]'); + const findImageTextInput = () => wrapper.find('[data-testid="metric-image-text-field"]'); + const findImageUrlInput = () => wrapper.find('[data-testid="metric-image-url-field"]'); + + const closeModal = () => findModal().vm.$emit('hidden'); + const submitModal = () => findModal().vm.$emit('primary', mockEvent); + const deleteImage = () => findDeleteButton().vm.$emit('click'); + const closeEditModal = () => findEditModal().vm.$emit('hidden'); + const submitEditModal = () => findEditModal().vm.$emit('primary', mockEvent); + const editImage = () => findEditButton().vm.$emit('click'); + + it('render the metrics image component', () => { + mountComponent({}, shallowMount); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('shows a link with the correct url', () => { + const testUrl = 'test_url'; + mountComponent({ propsData: { url: testUrl } }); + + expect(findImageLink().attributes('href')).toBe(testUrl); + expect(findImageLink().text()).toBe(defaultProps.filename); + }); + + it('shows a link with the url text, if url text is present', () => { + const testUrl = 'test_url'; + const testUrlText = 'test_url_text'; + mountComponent({ propsData: { url: testUrl, urlText: testUrlText } }); + + expect(findImageLink().attributes('href')).toBe(testUrl); + expect(findImageLink().text()).toBe(testUrlText); + }); + + it('shows the url text with no url, if no url is present', () => { + const testUrlText = 'test_url_text'; + mountComponent({ propsData: { urlText: testUrlText } }); + + expect(findLabelTextSpan().text()).toBe(testUrlText); + }); + + describe('expand and collapse', () => { + beforeEach(() => { + mountComponent(); + }); + + it('the card is expanded by default', () => { + expect(findMetricImageBody().isVisible()).toBe(true); + }); + + it('the card is collapsed when clicked', async () => { + findCollapseButton().trigger('click'); + + await waitForPromises(); + + expect(findMetricImageBody().isVisible()).toBe(false); + }); + }); + + describe('delete functionality', () => { + it('should open the delete modal when clicked', async () => { + mountComponent({ stubs: { GlModal: true } }); + + deleteImage(); + + await waitForPromises(); + + expect(findModal().attributes('visible')).toBe('true'); + }); + + describe('when the modal is open', () => { + beforeEach(() => { + mountComponent( + { + data() { + return { modalVisible: true }; + }, + }, + shallowMount, + ); + }); + + it('should close the modal when cancelled', async () => { + closeModal(); + + await waitForPromises(); + + expect(findModal().attributes('visible')).toBeFalsy(); + }); + + it('should delete the image when selected', async () => { + const dispatchSpy = jest.spyOn(store, 'dispatch').mockImplementation(jest.fn()); + + submitModal(); + + await waitForPromises(); + + expect(dispatchSpy).toHaveBeenCalledWith('deleteImage', defaultProps.id); + }); + }); + + describe('canUpdate permission', () => { + it('delete button is hidden when user lacks update permissions', () => { + mountComponent({ provide: { canUpdate: false } }); + + expect(findDeleteButton().exists()).toBe(false); + }); + }); + }); + + describe('edit functionality', () => { + it('should open the delete modal when clicked', async () => { + mountComponent({ stubs: { GlModal: true } }); + + editImage(); + + await waitForPromises(); + + expect(findEditModal().attributes('visible')).toBe('true'); + }); + + describe('when the modal is open', () => { + beforeEach(() => { + mountComponent({ + data() { + return { editModalVisible: true }; + }, + propsData: { urlText: 'test' }, + stubs: { GlModal: true }, + }); + }); + + it('should close the modal when cancelled', async () => { + closeEditModal(); + + await waitForPromises(); + + expect(findEditModal().attributes('visible')).toBeFalsy(); + }); + + it('should delete the image when selected', async () => { + const dispatchSpy = jest.spyOn(store, 'dispatch').mockImplementation(jest.fn()); + + submitEditModal(); + + await waitForPromises(); + + expect(dispatchSpy).toHaveBeenCalledWith('updateImage', { + imageId: defaultProps.id, + url: null, + urlText: 'test', + }); + }); + + it('should clear edits when the modal is closed', async () => { + await findImageTextInput().setValue('test value'); + await findImageUrlInput().setValue('http://www.gitlab.com'); + + expect(findImageTextInput().element.value).toBe('test value'); + expect(findImageUrlInput().element.value).toBe('http://www.gitlab.com'); + + closeEditModal(); + + await waitForPromises(); + + editImage(); + + await waitForPromises(); + + expect(findImageTextInput().element.value).toBe('test'); + expect(findImageUrlInput().element.value).toBe(''); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/metric_images/mock_data.js b/spec/frontend/vue_shared/components/metric_images/mock_data.js new file mode 100644 index 00000000000..480491077fb --- /dev/null +++ b/spec/frontend/vue_shared/components/metric_images/mock_data.js @@ -0,0 +1,5 @@ +export const fileList = [{ filePath: 'test', filename: 'hello', id: 5, url: null }]; + +export const fileListRaw = [{ file_path: 'test', filename: 'hello', id: 5, url: null }]; + +export const initialData = { issueIid: '123', projectId: 456 }; diff --git a/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js b/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js new file mode 100644 index 00000000000..518cf354675 --- /dev/null +++ b/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js @@ -0,0 +1,158 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import actionsFactory from '~/vue_shared/components/metric_images/store/actions'; +import * as types from '~/vue_shared/components/metric_images/store/mutation_types'; +import createStore from '~/vue_shared/components/metric_images/store'; +import testAction from 'helpers/vuex_action_helper'; +import createFlash from '~/flash'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { fileList, initialData } from '../mock_data'; + +jest.mock('~/flash'); +const service = { + getMetricImages: jest.fn(), + uploadMetricImage: jest.fn(), + updateMetricImage: jest.fn(), + deleteMetricImage: jest.fn(), +}; + +const actions = actionsFactory(service); + +const defaultState = { + issueIid: 1, + projectId: '2', +}; + +Vue.use(Vuex); + +describe('Metrics tab store actions', () => { + let store; + let state; + + beforeEach(() => { + store = createStore(defaultState); + state = store.state; + }); + + afterEach(() => { + createFlash.mockClear(); + }); + + describe('fetching metric images', () => { + it('should call success action when fetching metric images', () => { + service.getMetricImages.mockImplementation(() => Promise.resolve(fileList)); + + testAction(actions.fetchImages, null, state, [ + { type: types.REQUEST_METRIC_IMAGES }, + { + type: types.RECEIVE_METRIC_IMAGES_SUCCESS, + payload: convertObjectPropsToCamelCase(fileList, { deep: true }), + }, + ]); + }); + + it('should call error action when fetching metric images with an error', async () => { + service.getMetricImages.mockImplementation(() => Promise.reject()); + + await testAction( + actions.fetchImages, + null, + state, + [{ type: types.REQUEST_METRIC_IMAGES }, { type: types.RECEIVE_METRIC_IMAGES_ERROR }], + [], + ); + expect(createFlash).toHaveBeenCalled(); + }); + }); + + describe('uploading metric images', () => { + const payload = { + // mock the FileList api + files: { + item() { + return fileList[0]; + }, + }, + url: 'test_url', + }; + + it('should call success action when uploading an image', () => { + service.uploadMetricImage.mockImplementation(() => Promise.resolve(fileList[0])); + + testAction(actions.uploadImage, payload, state, [ + { type: types.REQUEST_METRIC_UPLOAD }, + { + type: types.RECEIVE_METRIC_UPLOAD_SUCCESS, + payload: fileList[0], + }, + ]); + }); + + it('should call error action when failing to upload an image', async () => { + service.uploadMetricImage.mockImplementation(() => Promise.reject()); + + await testAction( + actions.uploadImage, + payload, + state, + [{ type: types.REQUEST_METRIC_UPLOAD }, { type: types.RECEIVE_METRIC_UPLOAD_ERROR }], + [], + ); + expect(createFlash).toHaveBeenCalled(); + }); + }); + + describe('updating metric images', () => { + const payload = { + url: 'test_url', + urlText: 'url text', + }; + + it('should call success action when updating an image', () => { + service.updateMetricImage.mockImplementation(() => Promise.resolve()); + + testAction(actions.updateImage, payload, state, [ + { type: types.REQUEST_METRIC_UPLOAD }, + { + type: types.RECEIVE_METRIC_UPDATE_SUCCESS, + }, + ]); + }); + + it('should call error action when failing to update an image', async () => { + service.updateMetricImage.mockImplementation(() => Promise.reject()); + + await testAction( + actions.updateImage, + payload, + state, + [{ type: types.REQUEST_METRIC_UPLOAD }, { type: types.RECEIVE_METRIC_UPLOAD_ERROR }], + [], + ); + expect(createFlash).toHaveBeenCalled(); + }); + }); + + describe('deleting a metric image', () => { + const payload = fileList[0].id; + + it('should call success action when deleting an image', () => { + service.deleteMetricImage.mockImplementation(() => Promise.resolve()); + + testAction(actions.deleteImage, payload, state, [ + { + type: types.RECEIVE_METRIC_DELETE_SUCCESS, + payload, + }, + ]); + }); + }); + + describe('initial data', () => { + it('should set the initial data correctly', () => { + testAction(actions.setInitialData, initialData, state, [ + { type: types.SET_INITIAL_DATA, payload: initialData }, + ]); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/metric_images/store/mutations_spec.js b/spec/frontend/vue_shared/components/metric_images/store/mutations_spec.js new file mode 100644 index 00000000000..754f729e657 --- /dev/null +++ b/spec/frontend/vue_shared/components/metric_images/store/mutations_spec.js @@ -0,0 +1,147 @@ +import { cloneDeep } from 'lodash'; +import * as types from '~/vue_shared/components/metric_images/store/mutation_types'; +import mutations from '~/vue_shared/components/metric_images/store/mutations'; +import { initialData } from '../mock_data'; + +const defaultState = { + metricImages: [], + isLoadingMetricImages: false, + isUploadingImage: false, +}; + +const testImages = [ + { filename: 'test.filename', id: 5, filePath: 'test/file/path', url: null }, + { filename: 'second.filename', id: 6, filePath: 'second/file/path', url: 'test/url' }, + { filename: 'third.filename', id: 7, filePath: 'third/file/path', url: 'test/url' }, +]; + +describe('Metric images mutations', () => { + let state; + + const createState = (customState = {}) => { + state = { + ...cloneDeep(defaultState), + ...customState, + }; + }; + + beforeEach(() => { + createState(); + }); + + describe('REQUEST_METRIC_IMAGES', () => { + beforeEach(() => { + mutations[types.REQUEST_METRIC_IMAGES](state); + }); + + it('should set the loading state', () => { + expect(state.isLoadingMetricImages).toBe(true); + }); + }); + + describe('RECEIVE_METRIC_IMAGES_SUCCESS', () => { + beforeEach(() => { + mutations[types.RECEIVE_METRIC_IMAGES_SUCCESS](state, testImages); + }); + + it('should unset the loading state', () => { + expect(state.isLoadingMetricImages).toBe(false); + }); + + it('should set the metric images', () => { + expect(state.metricImages).toEqual(testImages); + }); + }); + + describe('RECEIVE_METRIC_IMAGES_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_METRIC_IMAGES_ERROR](state); + }); + + it('should unset the loading state', () => { + expect(state.isLoadingMetricImages).toBe(false); + }); + }); + + describe('REQUEST_METRIC_UPLOAD', () => { + beforeEach(() => { + mutations[types.REQUEST_METRIC_UPLOAD](state); + }); + + it('should set the loading state', () => { + expect(state.isUploadingImage).toBe(true); + }); + }); + + describe('RECEIVE_METRIC_UPLOAD_SUCCESS', () => { + const initialImage = testImages[0]; + const newImage = testImages[1]; + + beforeEach(() => { + createState({ metricImages: [initialImage] }); + mutations[types.RECEIVE_METRIC_UPLOAD_SUCCESS](state, newImage); + }); + + it('should unset the loading state', () => { + expect(state.isUploadingImage).toBe(false); + }); + + it('should add the new metric image after the existing one', () => { + expect(state.metricImages).toMatchObject([initialImage, newImage]); + }); + }); + + describe('RECEIVE_METRIC_UPLOAD_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_METRIC_UPLOAD_ERROR](state); + }); + + it('should unset the loading state', () => { + expect(state.isUploadingImage).toBe(false); + }); + }); + + describe('RECEIVE_METRIC_UPDATE_SUCCESS', () => { + const initialImage = testImages[0]; + const newImage = testImages[0]; + newImage.url = 'https://www.gitlab.com'; + + beforeEach(() => { + createState({ metricImages: [initialImage] }); + mutations[types.RECEIVE_METRIC_UPDATE_SUCCESS](state, newImage); + }); + + it('should unset the loading state', () => { + expect(state.isUploadingImage).toBe(false); + }); + + it('should replace the existing image with the new one', () => { + expect(state.metricImages).toMatchObject([newImage]); + }); + }); + + describe('RECEIVE_METRIC_DELETE_SUCCESS', () => { + const deletedImageId = testImages[1].id; + const expectedResult = [testImages[0], testImages[2]]; + + beforeEach(() => { + createState({ metricImages: [...testImages] }); + mutations[types.RECEIVE_METRIC_DELETE_SUCCESS](state, deletedImageId); + }); + + it('should remove the correct metric image', () => { + expect(state.metricImages).toEqual(expectedResult); + }); + }); + + describe('SET_INITIAL_DATA', () => { + beforeEach(() => { + mutations[types.SET_INITIAL_DATA](state, initialData); + }); + + it('should unset the loading state', () => { + expect(state.modelIid).toBe(initialData.modelIid); + expect(state.projectId).toBe(initialData.projectId); + }); + }); +}); 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 c8dab0204d3..6881cb79740 100644 --- a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js @@ -3,7 +3,7 @@ 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 '../../../notes/mock_data'; +import { userDataMock } from 'jest/notes/mock_data'; Vue.use(Vuex); diff --git a/spec/frontend/vue_shared/components/project_avatar/default_spec.js b/spec/frontend/vue_shared/components/project_avatar/default_spec.js deleted file mode 100644 index d042db6051c..00000000000 --- a/spec/frontend/vue_shared/components/project_avatar/default_spec.js +++ /dev/null @@ -1,50 +0,0 @@ -import Vue, { nextTick } from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import { projectData } from 'jest/ide/mock_data'; -import { TEST_HOST } from 'spec/test_constants'; -import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility'; -import ProjectAvatarDefault from '~/vue_shared/components/deprecated_project_avatar/default.vue'; - -describe('ProjectAvatarDefault component', () => { - const Component = Vue.extend(ProjectAvatarDefault); - let vm; - - beforeEach(() => { - vm = mountComponent(Component, { - project: projectData, - }); - }); - - afterEach(() => { - vm.$destroy(); - }); - - it('renders identicon if project has no avatar_url', async () => { - const expectedText = getFirstCharacterCapitalized(projectData.name); - - vm.project = { - ...vm.project, - avatar_url: null, - }; - - await nextTick(); - const identiconEl = vm.$el.querySelector('.identicon'); - - expect(identiconEl).not.toBe(null); - expect(identiconEl.textContent.trim()).toEqual(expectedText); - }); - - it('renders avatar image if project has avatar_url', async () => { - const avatarUrl = `${TEST_HOST}/images/home/nasa.svg`; - - vm.project = { - ...vm.project, - avatar_url: avatarUrl, - }; - - await nextTick(); - expect(vm.$el.querySelector('.avatar')).not.toBeNull(); - expect(vm.$el.querySelector('.identicon')).toBeNull(); - expect(vm.$el.querySelector('img')).toHaveAttr('src', avatarUrl); - }); -}); diff --git a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js index 5afa017aa76..397ab2254b9 100644 --- a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js +++ b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js @@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import mockProjects from 'test_fixtures_static/projects.json'; import { trimText } from 'helpers/text_helper'; -import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/default.vue'; +import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue'; describe('ProjectListItem component', () => { @@ -52,8 +52,13 @@ describe('ProjectListItem component', () => { it(`renders the project avatar`, () => { wrapper = shallowMount(Component, options); + const avatar = wrapper.findComponent(ProjectAvatar); - expect(wrapper.findComponent(ProjectAvatar).exists()).toBe(true); + expect(avatar.exists()).toBe(true); + expect(avatar.props()).toMatchObject({ + projectAvatarUrl: '', + projectName: project.name_with_namespace, + }); }); it(`renders a simple namespace name with a trailing slash`, () => { diff --git a/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js b/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js index c65ded000d3..616fefe847e 100644 --- a/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js +++ b/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js @@ -36,10 +36,10 @@ describe('Persisted dropdown selection', () => { }); describe('local storage sync', () => { - it('uses the local storage sync component', () => { + it('uses the local storage sync component with the correct props', () => { createComponent(); - expect(findLocalStorageSync().exists()).toBe(true); + expect(findLocalStorageSync().props('asString')).toBe(true); }); it('passes the right props', () => { 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 6954bd5ccff..ac313e556fc 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,7 +42,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = ` <gl-accordion-item-stub class="gl-font-weight-normal" title="More Details" - title-visible="Less Details" + titlevisible="Less Details" > <p class="gl-pt-2" @@ -76,7 +76,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = ` <gl-accordion-item-stub class="gl-font-weight-normal" title="More Details" - title-visible="Less Details" + titlevisible="Less Details" > <p class="gl-pt-2" @@ -110,7 +110,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = ` <gl-accordion-item-stub class="gl-font-weight-normal" title="More Details" - title-visible="Less Details" + titlevisible="Less Details" > <p class="gl-pt-2" @@ -144,7 +144,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = ` <gl-accordion-item-stub class="gl-font-weight-normal" title="More Details" - title-visible="Less Details" + titlevisible="Less Details" > <p class="gl-pt-2" diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js index 2e4c056df61..2bc513e87bf 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js @@ -21,87 +21,81 @@ describe('LabelsSelect Actions', () => { }); describe('setInitialState', () => { - it('sets initial store state', (done) => { - testAction( + it('sets initial store state', () => { + return testAction( actions.setInitialState, mockInitialState, state, [{ type: types.SET_INITIAL_STATE, payload: mockInitialState }], [], - done, ); }); }); describe('toggleDropdownButton', () => { - it('toggles dropdown button', (done) => { - testAction( + it('toggles dropdown button', () => { + return testAction( actions.toggleDropdownButton, {}, state, [{ type: types.TOGGLE_DROPDOWN_BUTTON }], [], - done, ); }); }); describe('toggleDropdownContents', () => { - it('toggles dropdown contents', (done) => { - testAction( + it('toggles dropdown contents', () => { + return testAction( actions.toggleDropdownContents, {}, state, [{ type: types.TOGGLE_DROPDOWN_CONTENTS }], [], - done, ); }); }); describe('toggleDropdownContentsCreateView', () => { - it('toggles dropdown create view', (done) => { - testAction( + it('toggles dropdown create view', () => { + return testAction( actions.toggleDropdownContentsCreateView, {}, state, [{ type: types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW }], [], - done, ); }); }); describe('requestLabels', () => { - it('sets value of `state.labelsFetchInProgress` to `true`', (done) => { - testAction(actions.requestLabels, {}, state, [{ type: types.REQUEST_LABELS }], [], done); + it('sets value of `state.labelsFetchInProgress` to `true`', () => { + return testAction(actions.requestLabels, {}, state, [{ type: types.REQUEST_LABELS }], []); }); }); describe('receiveLabelsSuccess', () => { - it('sets provided labels to `state.labels`', (done) => { + it('sets provided labels to `state.labels`', () => { const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - testAction( + return testAction( actions.receiveLabelsSuccess, labels, state, [{ type: types.RECEIVE_SET_LABELS_SUCCESS, payload: labels }], [], - done, ); }); }); describe('receiveLabelsFailure', () => { - it('sets value `state.labelsFetchInProgress` to `false`', (done) => { - testAction( + it('sets value `state.labelsFetchInProgress` to `false`', () => { + return testAction( actions.receiveLabelsFailure, {}, state, [{ type: types.RECEIVE_SET_LABELS_FAILURE }], [], - done, ); }); @@ -125,72 +119,67 @@ describe('LabelsSelect Actions', () => { }); describe('on success', () => { - it('dispatches `requestLabels` & `receiveLabelsSuccess` actions', (done) => { + it('dispatches `requestLabels` & `receiveLabelsSuccess` actions', () => { const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; mock.onGet(/labels.json/).replyOnce(200, labels); - testAction( + return testAction( actions.fetchLabels, {}, state, [], [{ type: 'requestLabels' }, { type: 'receiveLabelsSuccess', payload: labels }], - done, ); }); }); describe('on failure', () => { - it('dispatches `requestLabels` & `receiveLabelsFailure` actions', (done) => { + it('dispatches `requestLabels` & `receiveLabelsFailure` actions', () => { mock.onGet(/labels.json/).replyOnce(500, {}); - testAction( + return testAction( actions.fetchLabels, {}, state, [], [{ type: 'requestLabels' }, { type: 'receiveLabelsFailure' }], - done, ); }); }); }); describe('requestCreateLabel', () => { - it('sets value `state.labelCreateInProgress` to `true`', (done) => { - testAction( + it('sets value `state.labelCreateInProgress` to `true`', () => { + return testAction( actions.requestCreateLabel, {}, state, [{ type: types.REQUEST_CREATE_LABEL }], [], - done, ); }); }); describe('receiveCreateLabelSuccess', () => { - it('sets value `state.labelCreateInProgress` to `false`', (done) => { - testAction( + it('sets value `state.labelCreateInProgress` to `false`', () => { + return testAction( actions.receiveCreateLabelSuccess, {}, state, [{ type: types.RECEIVE_CREATE_LABEL_SUCCESS }], [], - done, ); }); }); describe('receiveCreateLabelFailure', () => { - it('sets value `state.labelCreateInProgress` to `false`', (done) => { - testAction( + it('sets value `state.labelCreateInProgress` to `false`', () => { + return testAction( actions.receiveCreateLabelFailure, {}, state, [{ type: types.RECEIVE_CREATE_LABEL_FAILURE }], [], - done, ); }); @@ -214,11 +203,11 @@ describe('LabelsSelect Actions', () => { }); describe('on success', () => { - it('dispatches `requestCreateLabel`, `fetchLabels` & `receiveCreateLabelSuccess` & `toggleDropdownContentsCreateView` actions', (done) => { + it('dispatches `requestCreateLabel`, `fetchLabels` & `receiveCreateLabelSuccess` & `toggleDropdownContentsCreateView` actions', () => { const label = { id: 1 }; mock.onPost(/labels.json/).replyOnce(200, label); - testAction( + return testAction( actions.createLabel, {}, state, @@ -229,38 +218,35 @@ describe('LabelsSelect Actions', () => { { type: 'receiveCreateLabelSuccess' }, { type: 'toggleDropdownContentsCreateView' }, ], - done, ); }); }); describe('on failure', () => { - it('dispatches `requestCreateLabel` & `receiveCreateLabelFailure` actions', (done) => { + it('dispatches `requestCreateLabel` & `receiveCreateLabelFailure` actions', () => { mock.onPost(/labels.json/).replyOnce(500, {}); - testAction( + return testAction( actions.createLabel, {}, state, [], [{ type: 'requestCreateLabel' }, { type: 'receiveCreateLabelFailure' }], - done, ); }); }); }); describe('updateSelectedLabels', () => { - it('updates `state.labels` based on provided `labels` param', (done) => { + it('updates `state.labels` based on provided `labels` param', () => { const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - testAction( + return testAction( actions.updateSelectedLabels, labels, state, [{ type: types.UPDATE_SELECTED_LABELS, payload: { labels } }], [], - done, ); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js index 67e1a3ce932..1b27a294b90 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js @@ -11,9 +11,15 @@ import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/ import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql'; import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql'; +import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labels.subscription.graphql'; import updateEpicLabelsMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql'; import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; -import { mockConfig, issuableLabelsQueryResponse, updateLabelsMutationResponse } from './mock_data'; +import { + mockConfig, + issuableLabelsQueryResponse, + updateLabelsMutationResponse, + issuableLabelsSubscriptionResponse, +} from './mock_data'; jest.mock('~/flash'); @@ -21,6 +27,7 @@ Vue.use(VueApollo); const successfulQueryHandler = jest.fn().mockResolvedValue(issuableLabelsQueryResponse); const successfulMutationHandler = jest.fn().mockResolvedValue(updateLabelsMutationResponse); +const subscriptionHandler = jest.fn().mockResolvedValue(issuableLabelsSubscriptionResponse); const errorQueryHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); const updateLabelsMutation = { @@ -42,10 +49,12 @@ describe('LabelsSelectRoot', () => { issuableType = IssuableType.Issue, queryHandler = successfulQueryHandler, mutationHandler = successfulMutationHandler, + isRealtimeEnabled = false, } = {}) => { const mockApollo = createMockApollo([ [issueLabelsQuery, queryHandler], [updateLabelsMutation[issuableType], mutationHandler], + [issuableLabelsSubscription, subscriptionHandler], ]); wrapper = shallowMount(LabelsSelectRoot, { @@ -65,6 +74,9 @@ describe('LabelsSelectRoot', () => { allowLabelEdit: true, allowLabelCreate: true, labelsManagePath: 'test', + glFeatures: { + realtimeLabels: isRealtimeEnabled, + }, }, }); }; @@ -190,5 +202,26 @@ describe('LabelsSelectRoot', () => { message: 'An error occurred while updating labels.', }); }); + + it('does not emit `updateSelectedLabels` event when the subscription is triggered and FF is disabled', async () => { + createComponent(); + await waitForPromises(); + + expect(wrapper.emitted('updateSelectedLabels')).toBeUndefined(); + }); + + it('emits `updateSelectedLabels` event when the subscription is triggered and FF is enabled', async () => { + createComponent({ isRealtimeEnabled: true }); + await waitForPromises(); + + expect(wrapper.emitted('updateSelectedLabels')).toEqual([ + [ + { + id: '1', + labels: issuableLabelsSubscriptionResponse.data.issuableLabelsUpdated.labels.nodes, + }, + ], + ]); + }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js index 49224fb915c..afad9314ace 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js @@ -141,6 +141,34 @@ export const issuableLabelsQueryResponse = { }, }; +export const issuableLabelsSubscriptionResponse = { + data: { + issuableLabelsUpdated: { + id: '1', + labels: { + nodes: [ + { + __typename: 'Label', + color: '#330066', + description: null, + id: 'gid://gitlab/ProjectLabel/1', + title: 'Label1', + textColor: '#000000', + }, + { + __typename: 'Label', + color: '#000000', + description: null, + id: 'gid://gitlab/ProjectLabel/2', + title: 'Label2', + textColor: '#ffffff', + }, + ], + }, + }, + }, +}; + export const updateLabelsMutationResponse = { data: { updateIssuableLabels: { diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js new file mode 100644 index 00000000000..eb2eec92534 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js @@ -0,0 +1,69 @@ +import { GlLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue'; +import { + BIDI_CHARS, + BIDI_CHARS_CLASS_LIST, + BIDI_CHAR_TOOLTIP, +} from '~/vue_shared/components/source_viewer/constants'; + +const DEFAULT_PROPS = { + number: 2, + content: '// Line content', + language: 'javascript', +}; + +describe('Chunk Line component', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(ChunkLine, { propsData: { ...DEFAULT_PROPS, ...props } }); + }; + + const findLink = () => wrapper.findComponent(GlLink); + const findContent = () => wrapper.findByTestId('content'); + const findWrappedBidiChars = () => wrapper.findAllByTestId('bidi-wrapper'); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => wrapper.destroy()); + + describe('rendering', () => { + it('wraps BiDi characters', () => { + const content = `// some content ${BIDI_CHARS.toString()} with BiDi chars`; + createComponent({ content }); + const wrappedBidiChars = findWrappedBidiChars(); + + expect(wrappedBidiChars.length).toBe(BIDI_CHARS.length); + + wrappedBidiChars.wrappers.forEach((_, i) => { + expect(wrappedBidiChars.at(i).text()).toBe(BIDI_CHARS[i]); + expect(wrappedBidiChars.at(i).attributes()).toMatchObject({ + class: BIDI_CHARS_CLASS_LIST, + title: BIDI_CHAR_TOOLTIP, + }); + }); + }); + + it('renders a line number', () => { + expect(findLink().attributes()).toMatchObject({ + 'data-line-number': `${DEFAULT_PROPS.number}`, + to: `#L${DEFAULT_PROPS.number}`, + id: `L${DEFAULT_PROPS.number}`, + }); + + expect(findLink().text()).toBe(DEFAULT_PROPS.number.toString()); + }); + + it('renders content', () => { + expect(findContent().attributes()).toMatchObject({ + id: `LC${DEFAULT_PROPS.number}`, + lang: DEFAULT_PROPS.language, + }); + + expect(findContent().text()).toBe(DEFAULT_PROPS.content); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js new file mode 100644 index 00000000000..42c4f2eacb8 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js @@ -0,0 +1,82 @@ +import { GlIntersectionObserver } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue'; +import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue'; + +const DEFAULT_PROPS = { + chunkIndex: 2, + isHighlighted: false, + content: '// Line 1 content \n // Line 2 content', + startingFrom: 140, + totalLines: 50, + language: 'javascript', +}; + +describe('Chunk component', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(Chunk, { propsData: { ...DEFAULT_PROPS, ...props } }); + }; + + const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); + const findChunkLines = () => wrapper.findAllComponents(ChunkLine); + const findLineNumbers = () => wrapper.findAllByTestId('line-number'); + const findContent = () => wrapper.findByTestId('content'); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => wrapper.destroy()); + + describe('Intersection observer', () => { + it('renders an Intersection observer component', () => { + expect(findIntersectionObserver().exists()).toBe(true); + }); + + it('emits an appear event when intersection-observer appears', () => { + findIntersectionObserver().vm.$emit('appear'); + + expect(wrapper.emitted('appear')).toEqual([[DEFAULT_PROPS.chunkIndex]]); + }); + + it('does not emit an appear event is isHighlighted is true', () => { + createComponent({ isHighlighted: true }); + findIntersectionObserver().vm.$emit('appear'); + + expect(wrapper.emitted('appear')).toEqual(undefined); + }); + }); + + describe('rendering', () => { + it('does not render a Chunk Line component if isHighlighted is false', () => { + expect(findChunkLines().length).toBe(0); + }); + + it('renders simplified line numbers and content if isHighlighted is false', () => { + expect(findLineNumbers().length).toBe(DEFAULT_PROPS.totalLines); + + expect(findLineNumbers().at(0).attributes()).toMatchObject({ + 'data-line-number': `${DEFAULT_PROPS.startingFrom + 1}`, + href: `#L${DEFAULT_PROPS.startingFrom + 1}`, + id: `L${DEFAULT_PROPS.startingFrom + 1}`, + }); + + expect(findContent().text()).toBe(DEFAULT_PROPS.content); + }); + + it('renders Chunk Line components if isHighlighted is true', () => { + const splitContent = DEFAULT_PROPS.content.split('\n'); + createComponent({ isHighlighted: true }); + + expect(findChunkLines().length).toBe(splitContent.length); + + expect(findChunkLines().at(0).props()).toMatchObject({ + number: DEFAULT_PROPS.startingFrom + 1, + content: splitContent[0], + language: DEFAULT_PROPS.language, + }); + }); + }); +}); 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 ab579945e22..6a9ea75127d 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 @@ -1,24 +1,38 @@ import hljs from 'highlight.js/lib/core'; -import { GlLoadingIcon } from '@gitlab/ui'; -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; import VueRouter from 'vue-router'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue'; +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 LineNumbers from '~/vue_shared/components/line_numbers.vue'; import waitForPromises from 'helpers/wait_for_promises'; -import * as sourceViewerUtils from '~/vue_shared/components/source_viewer/utils'; +import LineHighlighter from '~/blob/line_highlighter'; +import eventHub from '~/notes/event_hub'; +jest.mock('~/blob/line_highlighter'); jest.mock('highlight.js/lib/core'); Vue.use(VueRouter); const router = new VueRouter(); +const generateContent = (content, totalLines = 1) => { + let generatedContent = ''; + for (let i = 0; i < totalLines; i += 1) { + generatedContent += `Line: ${i + 1} = ${content}\n`; + } + return generatedContent; +}; + +const execImmediately = (callback) => callback(); + describe('Source Viewer component', () => { let wrapper; const language = 'docker'; const mappedLanguage = ROUGE_TO_HLJS_LANGUAGE_MAP[language]; - const content = `// Some source code`; - const DEFAULT_BLOB_DATA = { language, rawTextBlob: content }; + const chunk1 = generateContent('// Some source code 1', 70); + 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 highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`; const createComponent = async (blob = {}) => { @@ -29,15 +43,13 @@ describe('Source Viewer component', () => { await waitForPromises(); }; - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findLineNumbers = () => wrapper.findComponent(LineNumbers); - const findHighlightedContent = () => wrapper.findByTestId('test-highlighted'); - const findFirstLine = () => wrapper.find('#LC1'); + const findChunks = () => wrapper.findAllComponents(Chunk); beforeEach(() => { hljs.highlight.mockImplementation(() => ({ value: highlightedContent })); hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent })); - jest.spyOn(sourceViewerUtils, 'wrapLines'); + jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately); + jest.spyOn(eventHub, '$emit'); return createComponent(); }); @@ -45,6 +57,8 @@ describe('Source Viewer component', () => { afterEach(() => wrapper.destroy()); describe('highlight.js', () => { + beforeEach(() => createComponent({ language: mappedLanguage })); + it('registers the language definition', async () => { const languageDefinition = await import(`highlight.js/lib/languages/${mappedLanguage}`); @@ -54,72 +68,51 @@ describe('Source Viewer component', () => { ); }); - it('highlights the content', () => { - expect(hljs.highlight).toHaveBeenCalledWith(content, { language: mappedLanguage }); + it('highlights the first chunk', () => { + expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage }); }); describe('auto-detects if a language cannot be loaded', () => { beforeEach(() => createComponent({ language: 'some_unknown_language' })); it('highlights the content with auto-detection', () => { - expect(hljs.highlightAuto).toHaveBeenCalledWith(content); + expect(hljs.highlightAuto).toHaveBeenCalledWith(chunk1.trim()); }); }); }); describe('rendering', () => { - it('renders a loading icon if no highlighted content is available yet', async () => { - hljs.highlight.mockImplementation(() => ({ value: null })); - await createComponent(); - - expect(findLoadingIcon().exists()).toBe(true); - }); + it('renders the first chunk', async () => { + const firstChunk = findChunks().at(0); - it('calls the wrapLines helper method with highlightedContent and mappedLanguage', () => { - expect(sourceViewerUtils.wrapLines).toHaveBeenCalledWith(highlightedContent, mappedLanguage); - }); - - it('renders Line Numbers', () => { - expect(findLineNumbers().props('lines')).toBe(1); - }); + expect(firstChunk.props('content')).toContain(chunk1); - it('renders the highlighted content', () => { - expect(findHighlightedContent().exists()).toBe(true); + expect(firstChunk.props()).toMatchObject({ + totalLines: 70, + startingFrom: 0, + }); }); - }); - describe('selecting a line', () => { - let firstLine; - let firstLineElement; + it('renders the second chunk', async () => { + const secondChunk = findChunks().at(1); - beforeEach(() => { - firstLine = findFirstLine(); - firstLineElement = firstLine.element; + expect(secondChunk.props('content')).toContain(chunk2.trim()); - jest.spyOn(firstLineElement, 'scrollIntoView'); - jest.spyOn(firstLineElement.classList, 'add'); - jest.spyOn(firstLineElement.classList, 'remove'); - }); - - it('adds the highlight (hll) class', async () => { - wrapper.vm.$router.push('#LC1'); - await nextTick(); - - expect(firstLineElement.classList.add).toHaveBeenCalledWith('hll'); + expect(secondChunk.props()).toMatchObject({ + totalLines: 70, + startingFrom: 70, + }); }); + }); - it('removes the highlight (hll) class from a previously highlighted line', async () => { - wrapper.vm.$router.push('#LC2'); - await nextTick(); - - expect(firstLineElement.classList.remove).toHaveBeenCalledWith('hll'); - }); + it('emits showBlobInteractionZones on the eventHub when chunk appears', () => { + findChunks().at(0).vm.$emit('appear'); + expect(eventHub.$emit).toBeCalledWith('showBlobInteractionZones', path); + }); - it('scrolls the line into view', () => { - expect(firstLineElement.scrollIntoView).toHaveBeenCalledWith({ - behavior: 'smooth', - block: 'center', - }); + describe('LineHighlighter', () => { + it('instantiates the lineHighlighter class', async () => { + expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' }); }); }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/utils_spec.js b/spec/frontend/vue_shared/components/source_viewer/utils_spec.js deleted file mode 100644 index 0631e7efd54..00000000000 --- a/spec/frontend/vue_shared/components/source_viewer/utils_spec.js +++ /dev/null @@ -1,26 +0,0 @@ -import { wrapLines } from '~/vue_shared/components/source_viewer/utils'; - -describe('Wrap lines', () => { - it.each` - content | language | output - ${'line 1'} | ${'javascript'} | ${'<span id="LC1" lang="javascript" class="line">line 1</span>'} - ${'line 1\nline 2'} | ${'html'} | ${`<span id="LC1" lang="html" class="line">line 1</span>\n<span id="LC2" lang="html" class="line">line 2</span>`} - ${'<span class="hljs-code">line 1\nline 2</span>'} | ${'html'} | ${`<span id="LC1" lang="html" class="hljs-code">line 1\n<span id="LC2" lang="html" class="line">line 2</span></span>`} - ${'<span class="hljs-code">```bash'} | ${'bash'} | ${'<span id="LC1" lang="bash" class="hljs-code">```bash'} - ${'<span class="hljs-code">```bash'} | ${'valid-language1'} | ${'<span id="LC1" lang="valid-language1" class="hljs-code">```bash'} - ${'<span class="hljs-code">```bash'} | ${'valid_language2'} | ${'<span id="LC1" lang="valid_language2" class="hljs-code">```bash'} - `('returns lines wrapped in spans containing line numbers', ({ content, language, output }) => { - expect(wrapLines(content, language)).toBe(output); - }); - - it.each` - language - ${'invalidLanguage>'} - ${'"invalidLanguage"'} - ${'<invalidLanguage'} - `('returns lines safely without XSS language is not valid', ({ language }) => { - expect(wrapLines('<span class="hljs-code">```bash', language)).toBe( - '<span id="LC1" lang="" class="hljs-code">```bash', - ); - }); -}); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js index f624f84eabd..5e05b54cb8c 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js @@ -109,19 +109,33 @@ describe('User Avatar Image Component', () => { default: ['Action!'], }; - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: PROVIDED_PROPS, - slots, + describe('when `tooltipText` is provided and no default slot', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { ...PROVIDED_PROPS }, + }); }); - }); - it('renders the tooltip slot', () => { - expect(wrapper.findComponent(GlTooltip).exists()).toBe(true); + it('renders the tooltip with `tooltipText` as content', () => { + expect(wrapper.findComponent(GlTooltip).text()).toBe(PROVIDED_PROPS.tooltipText); + }); }); - it('renders the tooltip content', () => { - expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]); + describe('when `tooltipText` and default slot is provided', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { ...PROVIDED_PROPS }, + slots, + }); + }); + + it('does not render `tooltipText` inside the tooltip', () => { + expect(wrapper.findComponent(GlTooltip).text()).not.toBe(PROVIDED_PROPS.tooltipText); + }); + + it('renders the content provided via default slot', () => { + expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js index 5051b2b9cae..2c1be6ec47e 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js @@ -90,33 +90,38 @@ describe('User Avatar Image Component', () => { }); }); - describe('dynamic tooltip content', () => { - const props = PROVIDED_PROPS; + describe('Dynamic tooltip content', () => { const slots = { default: ['Action!'], }; - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { props }, - slots, + describe('when `tooltipText` is provided and no default slot', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { ...PROVIDED_PROPS }, + }); }); - }); - it('renders the tooltip slot', () => { - expect(wrapper.findComponent(GlTooltip).exists()).toBe(true); + it('renders the tooltip with `tooltipText` as content', () => { + expect(wrapper.findComponent(GlTooltip).text()).toBe(PROVIDED_PROPS.tooltipText); + }); }); - it('renders the tooltip content', () => { - expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]); - }); + describe('when `tooltipText` and default slot is provided', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { ...PROVIDED_PROPS }, + slots, + }); + }); - it('does not render tooltip data attributes on avatar image', () => { - const avatarImg = wrapper.find('img'); + it('does not render `tooltipText` inside the tooltip', () => { + expect(wrapper.findComponent(GlTooltip).text()).not.toBe(PROVIDED_PROPS.tooltipText); + }); - expect(avatarImg.attributes('title')).toBeFalsy(); - expect(avatarImg.attributes('data-placement')).not.toBeDefined(); - expect(avatarImg.attributes('data-container')).not.toBeDefined(); + it('renders the content provided via default slot', () => { + expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js index 66bb234aef6..20ff0848cff 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js @@ -153,4 +153,29 @@ describe('UserAvatarList', () => { }); }); }); + + describe('additional styling for the image', () => { + it('should not add CSS class when feature flag `glAvatarForAllUserAvatars` is disabled', () => { + factory({ + propsData: { items: createList(1) }, + }); + + const link = wrapper.findComponent(UserAvatarLink); + expect(link.props('imgCssClasses')).not.toBe('gl-mr-3'); + }); + + it('should add CSS class when feature flag `glAvatarForAllUserAvatars` is enabled', () => { + factory({ + propsData: { items: createList(1) }, + provide: { + glFeatures: { + glAvatarForAllUserAvatars: true, + }, + }, + }); + + const link = wrapper.findComponent(UserAvatarLink); + expect(link.props('imgCssClasses')).toBe('gl-mr-3'); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/user_select_spec.js b/spec/frontend/vue_shared/components/user_select_spec.js index cb476910944..ec9128d5e38 100644 --- a/spec/frontend/vue_shared/components/user_select_spec.js +++ b/spec/frontend/vue_shared/components/user_select_spec.js @@ -16,7 +16,7 @@ import { searchResponseOnMR, projectMembersResponse, participantsQueryResponse, -} from '../../sidebar/mock_data'; +} from 'jest/sidebar/mock_data'; const assignee = { id: 'gid://gitlab/User/4', diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js index e79935f8fa6..040461f6be4 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -261,7 +261,10 @@ describe('Web IDE link component', () => { }); it('should update local storage when selection changes', async () => { - expect(findLocalStorageSync().props('value')).toBe(ACTION_WEB_IDE.key); + expect(findLocalStorageSync().props()).toMatchObject({ + asString: true, + value: ACTION_WEB_IDE.key, + }); findActionsButton().vm.$emit('select', ACTION_GITPOD.key); 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 64823cd4c6c..058cb30c1d5 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 @@ -1,4 +1,9 @@ -import { GlAlert, GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui'; +import { + GlAlert, + GlKeysetPagination, + GlDeprecatedSkeletonLoading as GlSkeletonLoading, + GlPagination, +} from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import VueDraggable from 'vuedraggable'; diff --git a/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js index 6af07273cf6..46bfd7eceb1 100644 --- a/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js +++ b/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js @@ -26,8 +26,8 @@ describe('sast report actions', () => { }); describe('setDiffEndpoint', () => { - it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, (done) => { - testAction( + it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, () => { + return testAction( actions.setDiffEndpoint, diffEndpoint, state, @@ -38,20 +38,19 @@ describe('sast report actions', () => { }, ], [], - done, ); }); }); describe('requestDiff', () => { - it(`should commit ${types.REQUEST_DIFF}`, (done) => { - testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], [], done); + it(`should commit ${types.REQUEST_DIFF}`, () => { + return testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], []); }); }); describe('receiveDiffSuccess', () => { - it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, (done) => { - testAction( + it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, () => { + return testAction( actions.receiveDiffSuccess, reports, state, @@ -62,14 +61,13 @@ describe('sast report actions', () => { }, ], [], - done, ); }); }); describe('receiveDiffError', () => { - it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, (done) => { - testAction( + it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, () => { + return testAction( actions.receiveDiffError, error, state, @@ -80,7 +78,6 @@ describe('sast report actions', () => { }, ], [], - done, ); }); }); @@ -107,9 +104,9 @@ describe('sast report actions', () => { .replyOnce(200, reports.enrichData); }); - it('should dispatch the `receiveDiffSuccess` action', (done) => { + it('should dispatch the `receiveDiffSuccess` action', () => { const { diff, enrichData } = reports; - testAction( + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, @@ -124,7 +121,6 @@ describe('sast report actions', () => { }, }, ], - done, ); }); }); @@ -135,10 +131,10 @@ describe('sast report actions', () => { mock.onGet(diffEndpoint).replyOnce(200, reports.diff); }); - it('should dispatch the `receiveDiffSuccess` action with empty enrich data', (done) => { + it('should dispatch the `receiveDiffSuccess` action with empty enrich data', () => { const { diff } = reports; const enrichData = []; - testAction( + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, @@ -153,7 +149,6 @@ describe('sast report actions', () => { }, }, ], - done, ); }); }); @@ -167,14 +162,13 @@ describe('sast report actions', () => { .replyOnce(404); }); - it('should dispatch the `receiveError` action', (done) => { - testAction( + it('should dispatch the `receiveError` action', () => { + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, [], [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], - done, ); }); }); @@ -188,14 +182,13 @@ describe('sast report actions', () => { .replyOnce(200, reports.enrichData); }); - it('should dispatch the `receiveDiffError` action', (done) => { - testAction( + it('should dispatch the `receiveDiffError` action', () => { + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, [], [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], - done, ); }); }); diff --git a/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js index d22fee864e7..4f4f653bb72 100644 --- a/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js +++ b/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js @@ -26,8 +26,8 @@ describe('secret detection report actions', () => { }); describe('setDiffEndpoint', () => { - it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, (done) => { - testAction( + it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, () => { + return testAction( actions.setDiffEndpoint, diffEndpoint, state, @@ -38,20 +38,19 @@ describe('secret detection report actions', () => { }, ], [], - done, ); }); }); describe('requestDiff', () => { - it(`should commit ${types.REQUEST_DIFF}`, (done) => { - testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], [], done); + it(`should commit ${types.REQUEST_DIFF}`, () => { + return testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], []); }); }); describe('receiveDiffSuccess', () => { - it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, (done) => { - testAction( + it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, () => { + return testAction( actions.receiveDiffSuccess, reports, state, @@ -62,14 +61,13 @@ describe('secret detection report actions', () => { }, ], [], - done, ); }); }); describe('receiveDiffError', () => { - it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, (done) => { - testAction( + it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, () => { + return testAction( actions.receiveDiffError, error, state, @@ -80,7 +78,6 @@ describe('secret detection report actions', () => { }, ], [], - done, ); }); }); @@ -107,9 +104,10 @@ describe('secret detection report actions', () => { .replyOnce(200, reports.enrichData); }); - it('should dispatch the `receiveDiffSuccess` action', (done) => { + it('should dispatch the `receiveDiffSuccess` action', () => { const { diff, enrichData } = reports; - testAction( + + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, @@ -124,7 +122,6 @@ describe('secret detection report actions', () => { }, }, ], - done, ); }); }); @@ -135,10 +132,10 @@ describe('secret detection report actions', () => { mock.onGet(diffEndpoint).replyOnce(200, reports.diff); }); - it('should dispatch the `receiveDiffSuccess` action with empty enrich data', (done) => { + it('should dispatch the `receiveDiffSuccess` action with empty enrich data', () => { const { diff } = reports; const enrichData = []; - testAction( + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, @@ -153,7 +150,6 @@ describe('secret detection report actions', () => { }, }, ], - done, ); }); }); @@ -167,14 +163,13 @@ describe('secret detection report actions', () => { .replyOnce(404); }); - it('should dispatch the `receiveDiffError` action', (done) => { - testAction( + it('should dispatch the `receiveDiffError` action', () => { + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, [], [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], - done, ); }); }); @@ -188,14 +183,13 @@ describe('secret detection report actions', () => { .replyOnce(200, reports.enrichData); }); - it('should dispatch the `receiveDiffError` action', (done) => { - testAction( + it('should dispatch the `receiveDiffError` action', () => { + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, [], [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], - done, ); }); }); diff --git a/spec/frontend/vuex_shared/modules/modal/actions_spec.js b/spec/frontend/vuex_shared/modules/modal/actions_spec.js index c151049df2d..928ed7d0d5f 100644 --- a/spec/frontend/vuex_shared/modules/modal/actions_spec.js +++ b/spec/frontend/vuex_shared/modules/modal/actions_spec.js @@ -4,28 +4,28 @@ import * as types from '~/vuex_shared/modules/modal/mutation_types'; describe('Vuex ModalModule actions', () => { describe('open', () => { - it('works', (done) => { + it('works', () => { const data = { id: 7 }; - testAction(actions.open, data, {}, [{ type: types.OPEN, payload: data }], [], done); + return testAction(actions.open, data, {}, [{ type: types.OPEN, payload: data }], []); }); }); describe('close', () => { - it('works', (done) => { - testAction(actions.close, null, {}, [{ type: types.CLOSE }], [], done); + it('works', () => { + return testAction(actions.close, null, {}, [{ type: types.CLOSE }], []); }); }); describe('show', () => { - it('works', (done) => { - testAction(actions.show, null, {}, [{ type: types.SHOW }], [], done); + it('works', () => { + return testAction(actions.show, null, {}, [{ type: types.SHOW }], []); }); }); describe('hide', () => { - it('works', (done) => { - testAction(actions.hide, null, {}, [{ type: types.HIDE }], [], done); + it('works', () => { + return testAction(actions.hide, null, {}, [{ type: types.HIDE }], []); }); }); }); diff --git a/spec/frontend/work_items/components/item_title_spec.js b/spec/frontend/work_items/components/item_title_spec.js index 0f6e7091c59..0d85df25b4f 100644 --- a/spec/frontend/work_items/components/item_title_spec.js +++ b/spec/frontend/work_items/components/item_title_spec.js @@ -4,10 +4,10 @@ import ItemTitle from '~/work_items/components/item_title.vue'; jest.mock('lodash/escape', () => jest.fn((fn) => fn)); -const createComponent = ({ initialTitle = 'Sample title', disabled = false } = {}) => +const createComponent = ({ title = 'Sample title', disabled = false } = {}) => shallowMount(ItemTitle, { propsData: { - initialTitle, + title, disabled, }, }); diff --git a/spec/frontend/work_items/components/work_item_actions_spec.js b/spec/frontend/work_items/components/work_item_actions_spec.js new file mode 100644 index 00000000000..d0e9cfee353 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_actions_spec.js @@ -0,0 +1,103 @@ +import { GlDropdownItem, GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import WorkItemActions from '~/work_items/components/work_item_actions.vue'; +import deleteWorkItem from '~/work_items/graphql/delete_work_item.mutation.graphql'; +import { deleteWorkItemResponse, deleteWorkItemFailureResponse } from '../mock_data'; + +describe('WorkItemActions component', () => { + let wrapper; + let glModalDirective; + + Vue.use(VueApollo); + + const findModal = () => wrapper.findComponent(GlModal); + const findDeleteButton = () => wrapper.findComponent(GlDropdownItem); + + const createComponent = ({ + canUpdate = true, + deleteWorkItemHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse), + } = {}) => { + glModalDirective = jest.fn(); + wrapper = shallowMount(WorkItemActions, { + apolloProvider: createMockApollo([[deleteWorkItem, deleteWorkItemHandler]]), + propsData: { workItemId: '123', canUpdate }, + directives: { + glModal: { + bind(_, { value }) { + glModalDirective(value); + }, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders modal', () => { + createComponent(); + + expect(findModal().exists()).toBe(true); + expect(findModal().props('visible')).toBe(false); + }); + + it('shows confirm modal when clicking Delete work item', () => { + createComponent(); + + findDeleteButton().vm.$emit('click'); + + expect(glModalDirective).toHaveBeenCalled(); + }); + + it('calls delete mutation when clicking OK button', () => { + const deleteWorkItemHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse); + + createComponent({ + deleteWorkItemHandler, + }); + + findModal().vm.$emit('ok'); + + expect(deleteWorkItemHandler).toHaveBeenCalled(); + expect(wrapper.emitted('error')).toBeUndefined(); + }); + + it('emits event after delete success', async () => { + createComponent(); + + findModal().vm.$emit('ok'); + + await waitForPromises(); + + expect(wrapper.emitted('workItemDeleted')).not.toBeUndefined(); + expect(wrapper.emitted('error')).toBeUndefined(); + }); + + it('emits error event after delete failure', async () => { + createComponent({ + deleteWorkItemHandler: jest.fn().mockResolvedValue(deleteWorkItemFailureResponse), + }); + + findModal().vm.$emit('ok'); + + await waitForPromises(); + + expect(wrapper.emitted('error')[0]).toEqual([ + "The resource that you are attempting to access does not exist or you don't have permission to perform this action", + ]); + expect(wrapper.emitted('workItemDeleted')).toBeUndefined(); + }); + + it('does not render when canUpdate is false', () => { + createComponent({ + canUpdate: false, + }); + + expect(wrapper.html()).toBe(''); + }); +}); 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 new file mode 100644 index 00000000000..9f35ccb853b --- /dev/null +++ b/spec/frontend/work_items/components/work_item_detail_modal_spec.js @@ -0,0 +1,58 @@ +import { GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; +import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; +import WorkItemActions from '~/work_items/components/work_item_actions.vue'; + +describe('WorkItemDetailModal component', () => { + let wrapper; + + Vue.use(VueApollo); + + const findModal = () => wrapper.findComponent(GlModal); + const findWorkItemActions = () => wrapper.findComponent(WorkItemActions); + const findWorkItemDetail = () => wrapper.findComponent(WorkItemDetail); + + const createComponent = ({ visible = true, workItemId = '1', canUpdate = false } = {}) => { + wrapper = shallowMount(WorkItemDetailModal, { + propsData: { visible, workItemId, canUpdate }, + stubs: { + GlModal, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each([true, false])('when visible=%s', (visible) => { + it(`${visible ? 'renders' : 'does not render'} modal`, () => { + createComponent({ visible }); + + expect(findModal().props('visible')).toBe(visible); + }); + }); + + it('renders heading', () => { + createComponent(); + + expect(wrapper.find('h2').text()).toBe('Work Item'); + }); + + it('renders WorkItemDetail', () => { + createComponent(); + + expect(findWorkItemDetail().props()).toEqual({ workItemId: '1' }); + }); + + it('shows work item actions', () => { + createComponent({ + canUpdate: true, + }); + + expect(findWorkItemActions().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js deleted file mode 100644 index 305f43ad8ba..00000000000 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ /dev/null @@ -1,40 +0,0 @@ -import { GlModal } 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 WorkItemTitle from '~/work_items/components/item_title.vue'; -import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; -import { resolvers } from '~/work_items/graphql/resolvers'; - -describe('WorkItemDetailModal component', () => { - let wrapper; - - Vue.use(VueApollo); - - const findModal = () => wrapper.findComponent(GlModal); - const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle); - - const createComponent = () => { - wrapper = shallowMount(WorkItemDetailModal, { - apolloProvider: createMockApollo([], resolvers), - propsData: { visible: true }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders modal', () => { - createComponent(); - - expect(findModal().props()).toMatchObject({ visible: true }); - }); - - it('renders work item title', () => { - createComponent(); - - expect(findWorkItemTitle().exists()).toBe(true); - }); -}); diff --git a/spec/frontend/work_items/components/work_item_title_spec.js b/spec/frontend/work_items/components/work_item_title_spec.js new file mode 100644 index 00000000000..9b1ef2d14e4 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_title_spec.js @@ -0,0 +1,117 @@ +import { GlLoadingIcon } 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 { mockTracking } from 'helpers/tracking_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import ItemTitle from '~/work_items/components/item_title.vue'; +import WorkItemTitle from '~/work_items/components/work_item_title.vue'; +import { i18n } from '~/work_items/constants'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import { updateWorkItemMutationResponse, workItemQueryResponse } from '../mock_data'; + +describe('WorkItemTitle component', () => { + let wrapper; + + Vue.use(VueApollo); + + const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findItemTitle = () => wrapper.findComponent(ItemTitle); + + const createComponent = ({ loading = false, mutationHandler = mutationSuccessHandler } = {}) => { + const { id, title, workItemType } = workItemQueryResponse.data.workItem; + wrapper = shallowMount(WorkItemTitle, { + apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]), + propsData: { + loading, + workItemId: id, + workItemTitle: title, + workItemType: workItemType.name, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when loading', () => { + beforeEach(() => { + createComponent({ loading: true }); + }); + + it('renders loading spinner', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not render title', () => { + expect(findItemTitle().exists()).toBe(false); + }); + }); + + describe('when loaded', () => { + beforeEach(() => { + createComponent({ loading: false }); + }); + + it('does not render loading spinner', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('renders title', () => { + expect(findItemTitle().props('title')).toBe(workItemQueryResponse.data.workItem.title); + }); + }); + + describe('when updating the title', () => { + it('calls a mutation', () => { + const title = 'new title!'; + + createComponent(); + + findItemTitle().vm.$emit('title-changed', title); + + expect(mutationSuccessHandler).toHaveBeenCalledWith({ + input: { + id: workItemQueryResponse.data.workItem.id, + title, + }, + }); + }); + + it('does not call a mutation when the title has not changed', () => { + createComponent(); + + findItemTitle().vm.$emit('title-changed', workItemQueryResponse.data.workItem.title); + + expect(mutationSuccessHandler).not.toHaveBeenCalled(); + }); + + it('emits an error message when the mutation was unsuccessful', async () => { + createComponent({ mutationHandler: jest.fn().mockRejectedValue('Error!') }); + + findItemTitle().vm.$emit('title-changed', 'new title'); + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[i18n.updateError]]); + }); + + it('tracks editing the title', async () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + createComponent(); + + findItemTitle().vm.$emit('title-changed', 'new title'); + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledWith('workItems:show', 'updated_title', { + category: 'workItems:show', + label: 'item_title', + property: 'type_Task', + }); + }); + }); +}); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 832795fc4ac..722e1708c15 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -1,21 +1,14 @@ export const workItemQueryResponse = { - workItem: { - __typename: 'WorkItem', - id: '1', - title: 'Test', - workItemType: { - __typename: 'WorkItemType', - id: 'work-item-type-1', - }, - widgets: { - __typename: 'LocalWorkItemWidgetConnection', - nodes: [ - { - __typename: 'LocalTitleWidget', - type: 'TITLE', - contentText: 'Test', - }, - ], + data: { + workItem: { + __typename: 'WorkItem', + id: 'gid://gitlab/WorkItem/1', + title: 'Test', + workItemType: { + __typename: 'WorkItemType', + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + }, }, }, }; @@ -23,25 +16,15 @@ export const workItemQueryResponse = { export const updateWorkItemMutationResponse = { data: { workItemUpdate: { - __typename: 'LocalUpdateWorkItemPayload', + __typename: 'WorkItemUpdatePayload', workItem: { - __typename: 'LocalWorkItem', - id: '1', + __typename: 'WorkItem', + id: 'gid://gitlab/WorkItem/1', title: 'Updated title', workItemType: { __typename: 'WorkItemType', - id: 'work-item-type-1', - }, - widgets: { - __typename: 'LocalWorkItemWidgetConnection', - nodes: [ - { - __typename: 'LocalTitleWidget', - type: 'TITLE', - enabled: true, - contentText: 'Updated title', - }, - ], + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', }, }, }, @@ -51,11 +34,11 @@ export const updateWorkItemMutationResponse = { export const projectWorkItemTypesQueryResponse = { data: { workspace: { - id: '1', + id: 'gid://gitlab/WorkItem/1', workItemTypes: { nodes: [ - { id: 'work-item-1', name: 'Issue' }, - { id: 'work-item-2', name: 'Incident' }, + { id: 'gid://gitlab/WorkItems::Type/1', name: 'Issue' }, + { id: 'gid://gitlab/WorkItems::Type/2', name: 'Incident' }, ], }, }, @@ -68,13 +51,53 @@ export const createWorkItemMutationResponse = { __typename: 'WorkItemCreatePayload', workItem: { __typename: 'WorkItem', - id: '1', + id: 'gid://gitlab/WorkItem/1', title: 'Updated title', workItemType: { __typename: 'WorkItemType', - id: 'work-item-type-1', + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', }, }, }, }, }; + +export const createWorkItemFromTaskMutationResponse = { + data: { + workItemCreateFromTask: { + __typename: 'WorkItemCreateFromTaskPayload', + errors: [], + workItem: { + descriptionHtml: '<p>New description</p>', + id: 'gid://gitlab/WorkItem/13', + __typename: 'WorkItem', + }, + }, + }, +}; + +export const deleteWorkItemResponse = { + data: { workItemDelete: { errors: [], __typename: 'WorkItemDeletePayload' } }, +}; + +export const deleteWorkItemFailureResponse = { + data: { workItemDelete: null }, + errors: [ + { + message: + "The resource that you are attempting to access does not exist or you don't have permission to perform this action", + locations: [{ line: 2, column: 3 }], + path: ['workItemDelete'], + }, + ], +}; + +export const workItemTitleSubscriptionResponse = { + data: { + issuableTitleUpdated: { + id: 'gid://gitlab/WorkItem/1', + title: 'new 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 185b05c5191..fb1f1d56356 100644 --- a/spec/frontend/work_items/pages/create_work_item_spec.js +++ b/spec/frontend/work_items/pages/create_work_item_spec.js @@ -1,15 +1,19 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -import { GlAlert, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlAlert, GlFormSelect } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; import ItemTitle from '~/work_items/components/item_title.vue'; -import { resolvers } from '~/work_items/graphql/resolvers'; import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql'; -import { projectWorkItemTypesQueryResponse, createWorkItemMutationResponse } from '../mock_data'; +import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql'; +import { + projectWorkItemTypesQueryResponse, + createWorkItemMutationResponse, + createWorkItemFromTaskMutationResponse, +} from '../mock_data'; jest.mock('~/lib/utils/uuids', () => ({ uuids: () => ['testuuid'] })); @@ -20,12 +24,15 @@ describe('Create work item component', () => { let fakeApollo; const querySuccessHandler = jest.fn().mockResolvedValue(projectWorkItemTypesQueryResponse); - const mutationSuccessHandler = jest.fn().mockResolvedValue(createWorkItemMutationResponse); + 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); const findTitleInput = () => wrapper.findComponent(ItemTitle); - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findSelect = () => wrapper.findComponent(GlFormSelect); const findCreateButton = () => wrapper.find('[data-testid="create-button"]'); const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]'); @@ -36,15 +43,13 @@ describe('Create work item component', () => { data = {}, props = {}, queryHandler = querySuccessHandler, - mutationHandler = mutationSuccessHandler, + mutationHandler = createWorkItemSuccessHandler, } = {}) => { - fakeApollo = createMockApollo( - [ - [projectWorkItemTypesQuery, queryHandler], - [createWorkItemMutation, mutationHandler], - ], - resolvers, - ); + fakeApollo = createMockApollo([ + [projectWorkItemTypesQuery, queryHandler], + [createWorkItemMutation, mutationHandler], + [createWorkItemFromTaskMutation, mutationHandler], + ]); wrapper = shallowMount(CreateWorkItem, { apolloProvider: fakeApollo, data() { @@ -123,6 +128,7 @@ describe('Create work item component', () => { props: { isModal: true, }, + mutationHandler: createWorkItemFromTaskSuccessHandler, }); }); @@ -133,14 +139,12 @@ describe('Create work item component', () => { }); it('emits `onCreate` on successful mutation', async () => { - const mockTitle = 'Test title'; findTitleInput().vm.$emit('title-input', 'Test title'); wrapper.find('form').trigger('submit'); await waitForPromises(); - const expected = { id: '1', title: mockTitle }; - expect(wrapper.emitted('onCreate')).toEqual([[expected]]); + expect(wrapper.emitted('onCreate')).toEqual([['<p>New description</p>']]); }); it('does not right margin for create button', () => { @@ -177,16 +181,14 @@ describe('Create work item component', () => { }); it('displays a list of work item types', () => { - expect(findDropdownItems()).toHaveLength(2); - expect(findDropdownItems().at(0).text()).toContain('Issue'); + expect(findSelect().attributes('options').split(',')).toHaveLength(3); }); it('selects a work item type on click', async () => { - expect(findDropdown().props('text')).toBe('Type'); - findDropdownItems().at(0).vm.$emit('click'); + const mockId = 'work-item-1'; + findSelect().vm.$emit('input', mockId); await nextTick(); - - expect(findDropdown().props('text')).toBe('Issue'); + expect(findSelect().attributes('value')).toBe(mockId); }); }); @@ -206,21 +208,36 @@ describe('Create work item component', () => { createComponent({ props: { initialTitle }, }); - expect(findTitleInput().props('initialTitle')).toBe(initialTitle); + expect(findTitleInput().props('title')).toBe(initialTitle); }); describe('when title input field has a text', () => { - beforeEach(() => { + beforeEach(async () => { const mockTitle = 'Test title'; createComponent(); + await waitForPromises(); findTitleInput().vm.$emit('title-input', mockTitle); }); - it('renders a non-disabled Create button', () => { + it('renders a disabled Create button', () => { + expect(findCreateButton().props('disabled')).toBe(true); + }); + + it('renders a non-disabled Create button when work item type is selected', async () => { + findSelect().vm.$emit('input', 'work-item-1'); + await nextTick(); expect(findCreateButton().props('disabled')).toBe(false); }); + }); + + it('shows an alert on mutation error', async () => { + createComponent({ mutationHandler: errorHandler }); + await waitForPromises(); + findTitleInput().vm.$emit('title-input', 'some title'); + findSelect().vm.$emit('input', 'work-item-1'); + wrapper.find('form').trigger('submit'); + await waitForPromises(); - // TODO: write a proper test here when we have a backend implementation - it.todo('shows an alert on mutation error'); + expect(findAlert().text()).toBe(CreateWorkItem.createErrorText); }); }); diff --git a/spec/frontend/work_items/pages/work_item_detail_spec.js b/spec/frontend/work_items/pages/work_item_detail_spec.js new file mode 100644 index 00000000000..1eb6c0145e7 --- /dev/null +++ b/spec/frontend/work_items/pages/work_item_detail_spec.js @@ -0,0 +1,99 @@ +import { GlAlert } 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 WorkItemDetail from '~/work_items/components/work_item_detail.vue'; +import WorkItemTitle from '~/work_items/components/work_item_title.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 { workItemTitleSubscriptionResponse, workItemQueryResponse } from '../mock_data'; + +describe('WorkItemDetail component', () => { + let wrapper; + + Vue.use(VueApollo); + + const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse); + const initialSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse); + + const findAlert = () => wrapper.findComponent(GlAlert); + const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle); + + const createComponent = ({ + workItemId = workItemQueryResponse.data.workItem.id, + handler = successHandler, + subscriptionHandler = initialSubscriptionHandler, + } = {}) => { + wrapper = shallowMount(WorkItemDetail, { + apolloProvider: createMockApollo([ + [workItemQuery, handler], + [workItemTitleSubscription, subscriptionHandler], + ]), + propsData: { workItemId }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when there is no `workItemId` prop', () => { + beforeEach(() => { + createComponent({ workItemId: null }); + }); + + it('skips the work item query', () => { + expect(successHandler).not.toHaveBeenCalled(); + }); + }); + + describe('when loading', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders WorkItemTitle in loading state', () => { + expect(findWorkItemTitle().props('loading')).toBe(true); + }); + }); + + describe('when loaded', () => { + beforeEach(() => { + createComponent(); + return waitForPromises(); + }); + + it('does not render WorkItemTitle in loading state', () => { + expect(findWorkItemTitle().props('loading')).toBe(false); + }); + }); + + it('shows an error message when the work item query was unsuccessful', async () => { + const errorHandler = jest.fn().mockRejectedValue('Oops'); + createComponent({ handler: errorHandler }); + await waitForPromises(); + + expect(errorHandler).toHaveBeenCalled(); + expect(findAlert().text()).toBe(i18n.fetchError); + }); + + it('shows an error message when WorkItemTitle emits an `error` event', async () => { + createComponent(); + + findWorkItemTitle().vm.$emit('error', i18n.updateError); + await waitForPromises(); + + expect(findAlert().text()).toBe(i18n.updateError); + }); + + it('calls the subscription', () => { + createComponent(); + + expect(initialSubscriptionHandler).toHaveBeenCalledWith({ + issuableId: workItemQueryResponse.data.workItem.id, + }); + }); +}); 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 728495e0e23..2803724b9af 100644 --- a/spec/frontend/work_items/pages/work_item_root_spec.js +++ b/spec/frontend/work_items/pages/work_item_root_spec.js @@ -1,108 +1,31 @@ -import Vue from 'vue'; 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 { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; -import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; import WorkItemsRoot from '~/work_items/pages/work_item_root.vue'; -import ItemTitle from '~/work_items/components/item_title.vue'; -import { resolvers } from '~/work_items/graphql/resolvers'; -import { workItemQueryResponse, updateWorkItemMutationResponse } from '../mock_data'; Vue.use(VueApollo); -const WORK_ITEM_ID = '1'; -const WORK_ITEM_GID = `gid://gitlab/WorkItem/${WORK_ITEM_ID}`; - describe('Work items root component', () => { - const mockUpdatedTitle = 'Updated title'; let wrapper; - let fakeApollo; - - const findTitle = () => wrapper.findComponent(ItemTitle); - const createComponent = ({ queryResponse = workItemQueryResponse } = {}) => { - fakeApollo = createMockApollo( - [[updateWorkItemMutation, jest.fn().mockResolvedValue(updateWorkItemMutationResponse)]], - resolvers, - { - possibleTypes: { - LocalWorkItemWidget: ['LocalTitleWidget'], - }, - }, - ); - fakeApollo.clients.defaultClient.cache.writeQuery({ - query: workItemQuery, - variables: { - id: WORK_ITEM_GID, - }, - data: queryResponse, - }); + const findWorkItemDetail = () => wrapper.findComponent(WorkItemDetail); + const createComponent = () => { wrapper = shallowMount(WorkItemsRoot, { propsData: { - id: WORK_ITEM_ID, + id: '1', }, - apolloProvider: fakeApollo, }); }; afterEach(() => { wrapper.destroy(); - fakeApollo = null; }); - it('renders the title', () => { + it('renders WorkItemDetail', () => { createComponent(); - expect(findTitle().exists()).toBe(true); - expect(findTitle().props('initialTitle')).toBe('Test'); - }); - - it('updates the title when it is edited', async () => { - createComponent(); - jest.spyOn(wrapper.vm.$apollo, 'mutate'); - - await findTitle().vm.$emit('title-changed', mockUpdatedTitle); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: updateWorkItemMutation, - variables: { - input: { - id: WORK_ITEM_GID, - title: mockUpdatedTitle, - }, - }, - }); - }); - - describe('tracking', () => { - let trackingSpy; - - beforeEach(() => { - trackingSpy = mockTracking('_category_', undefined, jest.spyOn); - - createComponent(); - }); - - afterEach(() => { - unmockTracking(); - }); - - it('tracks item title updates', async () => { - await findTitle().vm.$emit('title-changed', mockUpdatedTitle); - - await waitForPromises(); - - expect(trackingSpy).toHaveBeenCalledTimes(1); - expect(trackingSpy).toHaveBeenCalledWith('workItems:show', undefined, { - action: 'updated_title', - category: 'workItems:show', - label: 'item_title', - property: '[type_work_item]', - }); - }); + expect(findWorkItemDetail().props()).toEqual({ workItemId: 'gid://gitlab/WorkItem/1' }); }); }); diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js index 8c9054920a8..7e68c5e4f0e 100644 --- a/spec/frontend/work_items/router_spec.js +++ b/spec/frontend/work_items/router_spec.js @@ -37,7 +37,7 @@ describe('Work items router', () => { it('renders work item on `/1` route', async () => { await createComponent('/1'); - expect(wrapper.find(WorkItemsRoot).exists()).toBe(true); + expect(wrapper.findComponent(WorkItemsRoot).exists()).toBe(true); }); it('renders create work item page on `/new` route', async () => { |