diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-06-14 15:09:51 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-06-14 15:09:51 +0300 |
commit | 9223573b85bcfdd21953f52e0d2c5cb587e366a1 (patch) | |
tree | 7dfd09536b948d560fc442014a95a221327b6567 /spec | |
parent | 1fc72cb8765dab466da8555b70eb744a53a74a80 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
23 files changed, 734 insertions, 519 deletions
diff --git a/spec/finders/groups_finder_spec.rb b/spec/finders/groups_finder_spec.rb index 25f9331005d..23d73b48199 100644 --- a/spec/finders/groups_finder_spec.rb +++ b/spec/finders/groups_finder_spec.rb @@ -2,10 +2,10 @@ require 'spec_helper' -RSpec.describe GroupsFinder do +RSpec.describe GroupsFinder, feature_category: :groups_and_projects do include AdminModeHelper - shared_examples '#execute' do + describe '#execute' do let(:user) { create(:user) } describe 'root level groups' do @@ -376,16 +376,4 @@ RSpec.describe GroupsFinder do end end end - - describe '#execute' do - include_examples '#execute' - - context 'when use_traversal_ids_groups_finder feature flags is disabled' do - before do - stub_feature_flags(use_traversal_ids_groups_finder: false) - end - - include_examples '#execute' - end - end end diff --git a/spec/frontend/boards/components/board_card_move_to_position_spec.js b/spec/frontend/boards/components/board_card_move_to_position_spec.js index 8af772ba6d0..5f308be5580 100644 --- a/spec/frontend/boards/components/board_card_move_to_position_spec.js +++ b/spec/frontend/boards/components/board_card_move_to_position_spec.js @@ -51,9 +51,12 @@ describe('Board Card Move to position', () => { }; }; - const createComponent = (propsData) => { + const createComponent = (propsData, isApolloBoard = false) => { wrapper = shallowMount(BoardCardMoveToPosition, { store, + provide: { + isApolloBoard, + }, propsData: { item: mockIssue2, list: mockList, @@ -134,5 +137,39 @@ describe('Board Card Move to position', () => { }, ); }); + + describe('Apollo boards', () => { + beforeEach(() => { + createComponent({ index: itemIndex }, true); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it.each` + dropdownIndex | dropdownItem | trackLabel | positionInList + ${0} | ${dropdownOptions[0]} | ${'move_to_start'} | ${0} + ${1} | ${dropdownOptions[1]} | ${'move_to_end'} | ${-1} + `( + 'on click of dropdown index $dropdownIndex with label $dropdownLabel emits moveToPosition event with tracking label $trackLabel', + async ({ dropdownIndex, dropdownItem, trackLabel, positionInList }) => { + await findMoveToPositionDropdown().vm.$emit('shown'); + + expect(findDropdownItemAtIndex(dropdownIndex).text()).toBe(dropdownItem.text); + + await findMoveToPositionDropdown().vm.$emit('action', dropdownItem); + + expect(trackingSpy).toHaveBeenCalledWith('boards:list', 'click_toggle_button', { + category: 'boards:list', + label: trackLabel, + property: 'type_card', + }); + + expect(wrapper.emitted('moveToPosition')).toEqual([[positionInList]]); + }, + ); + }); }); }); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js index 40b04ca3e00..e9484cfce57 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js @@ -495,6 +495,21 @@ describe('Ci variable modal', () => { }); }); + describe('when the value is empty', () => { + beforeEach(() => { + const [variable] = mockVariables; + const emptyValueVariable = { ...variable, value: '' }; + createComponent({ + mountFn: mountExtended, + props: { selectedVariable: emptyValueVariable }, + }); + }); + + it('allows user to submit', () => { + expect(findAddorUpdateButton().attributes('disabled')).toBeUndefined(); + }); + }); + describe('when the mask state is invalid', () => { beforeEach(async () => { const [variable] = mockVariables; diff --git a/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap index 9451f35ac5b..0bbb44bb517 100644 --- a/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap +++ b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap @@ -11,13 +11,13 @@ exports[`Design management list item component when item appears in view after i exports[`Design management list item component with notes renders item with multiple comments 1`] = ` <router-link-stub ariacurrentvalue="page" - class="card gl-cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new gl-mb-0" + class="card gl-cursor-pointer text-plain js-design-list-item design-list-item gl-mb-0" event="click" tag="a" to="[object Object]" > <div - class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative" + class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative gl-rounded-top-base" > <!----> @@ -91,13 +91,13 @@ exports[`Design management list item component with notes renders item with mult exports[`Design management list item component with notes renders item with single comment 1`] = ` <router-link-stub ariacurrentvalue="page" - class="card gl-cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new gl-mb-0" + class="card gl-cursor-pointer text-plain js-design-list-item design-list-item gl-mb-0" event="click" tag="a" to="[object Object]" > <div - class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative" + class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative gl-rounded-top-base" > <!----> diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index 90caf956126..961ea27f0f4 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -227,7 +227,6 @@ describe('Design management index page', () => { it('has correct classes applied to design dropzone', () => { createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); expect(dropzoneClasses()).toContain('design-list-item'); - expect(dropzoneClasses()).toContain('design-list-item-new'); }); it('has correct classes applied to dropzone wrapper', () => { @@ -253,7 +252,6 @@ describe('Design management index page', () => { it('has correct classes applied to design dropzone', () => { expect(dropzoneClasses()).not.toContain('design-list-item'); - expect(dropzoneClasses()).not.toContain('design-list-item-new'); }); it('has correct classes applied to dropzone wrapper', () => { @@ -355,7 +353,6 @@ describe('Design management index page', () => { expect(wrapper.vm.filesToBeSaved).toEqual([{ name: 'test' }]); expect(wrapper.vm.isSaving).toBe(true); expect(dropzoneClasses()).toContain('design-list-item'); - expect(dropzoneClasses()).toContain('design-list-item-new'); }); it('sets isSaving', async () => { diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js index bc7c81d2390..888df06d6b9 100644 --- a/spec/frontend/diffs/store/utils_spec.js +++ b/spec/frontend/diffs/store/utils_spec.js @@ -715,6 +715,14 @@ describe('DiffsStoreUtils', () => { ).toBe('mode_changed'); }); + it('returns no_preview if key has no match', () => { + expect( + utils.getDiffMode({ + viewer: { name: 'no_preview' }, + }), + ).toBe('no_preview'); + }); + it('defaults to replaced', () => { expect(utils.getDiffMode({})).toBe('replaced'); }); diff --git a/spec/frontend/notes/components/diff_with_note_spec.js b/spec/frontend/notes/components/diff_with_note_spec.js index c352265654b..508f2ced4c4 100644 --- a/spec/frontend/notes/components/diff_with_note_spec.js +++ b/spec/frontend/notes/components/diff_with_note_spec.js @@ -3,6 +3,7 @@ import discussionFixture from 'test_fixtures/merge_requests/diff_discussion.json import imageDiscussionFixture from 'test_fixtures/merge_requests/image_diff_discussion.json'; import { createStore } from '~/mr_notes/stores'; import DiffWithNote from '~/notes/components/diff_with_note.vue'; +import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; describe('diff_with_note', () => { let store; @@ -20,6 +21,8 @@ describe('diff_with_note', () => { }, }; + const findDiffViewer = () => wrapper.findComponent(DiffViewer); + beforeEach(() => { store = createStore(); store.replaceState({ @@ -85,4 +88,43 @@ describe('diff_with_note', () => { expect(selectors.diffTable.exists()).toBe(false); }); }); + + describe('legacy diff note', () => { + const mockCommitId = 'abc123'; + + beforeEach(() => { + const diffDiscussion = { + ...discussionFixture[0], + commit_id: mockCommitId, + diff_file: { + ...discussionFixture[0].diff_file, + diff_refs: null, + viewer: { + ...discussionFixture[0].diff_file.viewer, + name: 'no_preview', + }, + }, + }; + + wrapper = shallowMount(DiffWithNote, { + propsData: { + discussion: diffDiscussion, + }, + store, + }); + }); + + it('shows file diff', () => { + expect(selectors.diffTable.exists()).toBe(false); + }); + + it('uses "no_preview" diff mode', () => { + expect(findDiffViewer().props('diffMode')).toBe('no_preview'); + }); + + it('falls back to discussion.commit_id for baseSha and headSha', () => { + expect(findDiffViewer().props('oldSha')).toBe(mockCommitId); + expect(findDiffViewer().props('newSha')).toBe(mockCommitId); + }); + }); }); diff --git a/spec/frontend/projects/compare/components/repo_dropdown_spec.js b/spec/frontend/projects/compare/components/repo_dropdown_spec.js index 0b1085470b8..44aaac21733 100644 --- a/spec/frontend/projects/compare/components/repo_dropdown_spec.js +++ b/spec/frontend/projects/compare/components/repo_dropdown_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import RepoDropdown from '~/projects/compare/components/repo_dropdown.vue'; @@ -13,10 +13,14 @@ describe('RepoDropdown component', () => { ...defaultProps, ...props, }, + stubs: { + GlCollapsibleListbox, + GlListboxItem, + }, }); }; - const findGlDropdown = () => wrapper.findComponent(GlDropdown); + const findGlCollapsibleListbox = () => wrapper.findComponent(GlCollapsibleListbox); const findHiddenInput = () => wrapper.find('input[type="hidden"]'); describe('Source Revision', () => { @@ -29,8 +33,10 @@ describe('RepoDropdown component', () => { }); it('displays the project name in the disabled dropdown', () => { - expect(findGlDropdown().props('text')).toBe(defaultProps.selectedProject.name); - expect(findGlDropdown().props('disabled')).toBe(true); + expect(findGlCollapsibleListbox().props('toggleText')).toBe( + defaultProps.selectedProject.name, + ); + expect(findGlCollapsibleListbox().props('disabled')).toBe(true); }); it('does not emit `changeTargetProject` event', async () => { @@ -57,18 +63,21 @@ describe('RepoDropdown component', () => { }); it('displays matching project name of the source revision initially in the dropdown', () => { - expect(findGlDropdown().props('text')).toBe(defaultProps.selectedProject.name); + expect(findGlCollapsibleListbox().props('toggleText')).toBe( + defaultProps.selectedProject.name, + ); }); - it('updates the hidden input value when onClick method is triggered', async () => { + it('updates the hidden input value when dropdown item is selected', () => { const repoId = '1'; - wrapper.vm.onClick({ id: repoId }); - await nextTick(); + findGlCollapsibleListbox().vm.$emit('select', repoId); expect(findHiddenInput().attributes('value')).toBe(repoId); }); it('emits `selectProject` event when another target project is selected', async () => { - findGlDropdown().findAllComponents(GlDropdownItem).at(0).vm.$emit('click'); + const repoId = '1'; + findGlCollapsibleListbox().vm.$emit('select', repoId); + await nextTick(); expect(wrapper.emitted('selectProject')[0][0]).toEqual({ diff --git a/spec/frontend/projects/project_new_spec.js b/spec/frontend/projects/project_new_spec.js index 8a1e9904a3f..54d0cfaa8c6 100644 --- a/spec/frontend/projects/project_new_spec.js +++ b/spec/frontend/projects/project_new_spec.js @@ -13,6 +13,8 @@ describe('New Project', () => { const mockKeyup = (el) => el.dispatchEvent(new KeyboardEvent('keyup')); const mockChange = (el) => el.dispatchEvent(new Event('change')); + const mockSubmit = () => + document.getElementById('new_project').dispatchEvent(new Event('submit')); beforeEach(() => { setHTMLFixture(` @@ -311,4 +313,35 @@ describe('New Project', () => { expect($projectName.value).toEqual(dummyProjectName); }); }); + + describe('project path trimming', () => { + beforeEach(() => { + projectNew.bindEvents(); + }); + + describe('when the project path field is filled in', () => { + const dirtyProjectPath = ' my-awesome-project '; + const cleanProjectPath = dirtyProjectPath.trim(); + + beforeEach(() => { + $projectPath.value = dirtyProjectPath; + mockSubmit(); + }); + + it('trims the project path on submit', () => { + expect($projectPath.value).not.toBe(dirtyProjectPath); + expect($projectPath.value).toBe(cleanProjectPath); + }); + }); + + describe('when the project path field is left empty', () => { + beforeEach(() => { + mockSubmit(); + }); + + it('leaves the field empty', () => { + expect($projectPath.value).toBe(''); + }); + }); + }); }); diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index 21b37f5cfbc..ecd617ca44b 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -16,7 +16,7 @@ import ForkSuggestion from '~/repository/components/fork_suggestion.vue'; import { loadViewer } from '~/repository/components/blob_viewers'; import DownloadViewer from '~/repository/components/blob_viewers/download_viewer.vue'; import EmptyViewer from '~/repository/components/blob_viewers/empty_viewer.vue'; -import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer_deprecated.vue'; +import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue'; import blobInfoQuery from 'shared_queries/repository/blob_info.query.graphql'; import projectInfoQuery from '~/repository/queries/project_info.query.graphql'; import userInfoQuery from '~/repository/queries/user_info.query.graphql'; diff --git a/spec/frontend/super_sidebar/components/sidebar_menu_spec.js b/spec/frontend/super_sidebar/components/sidebar_menu_spec.js index 19471f28fab..21e5220edd9 100644 --- a/spec/frontend/super_sidebar/components/sidebar_menu_spec.js +++ b/spec/frontend/super_sidebar/components/sidebar_menu_spec.js @@ -1,7 +1,8 @@ -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import { s__ } from '~/locale'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SidebarMenu from '~/super_sidebar/components/sidebar_menu.vue'; import PinnedSection from '~/super_sidebar/components/pinned_section.vue'; +import NavItem from '~/super_sidebar/components/nav_item.vue'; +import MenuSection from '~/super_sidebar/components/menu_section.vue'; import { PANELS_WITH_PINS } from '~/super_sidebar/constants'; import { sidebarData } from '../mock_data'; @@ -12,181 +13,142 @@ const menuItems = [ { id: 4, title: 'Also with subitems', items: [{ id: 41, title: 'Subitem' }] }, ]; -describe('SidebarMenu component', () => { +describe('Sidebar Menu', () => { let wrapper; - const createWrapper = (mockData) => { - wrapper = mountExtended(SidebarMenu, { + const createWrapper = (extraProps = {}) => { + wrapper = shallowMountExtended(SidebarMenu, { propsData: { - items: mockData.current_menu_items, - pinnedItemIds: mockData.pinned_items, - panelType: mockData.panel_type, - updatePinsUrl: mockData.update_pins_url, + items: sidebarData.current_menu_items, + pinnedItemIds: sidebarData.pinned_items, + panelType: sidebarData.panel_type, + updatePinsUrl: sidebarData.update_pins_url, + ...extraProps, }, }); }; + const findStaticItemsSection = () => wrapper.findByTestId('static-items-section'); + const findStaticItems = () => findStaticItemsSection().findAllComponents(NavItem); const findPinnedSection = () => wrapper.findComponent(PinnedSection); const findMainMenuSeparator = () => wrapper.findByTestId('main-menu-separator'); - - describe('computed', () => { - describe('supportsPins', () => { - it('is true for the project sidebar', () => { - createWrapper({ ...sidebarData, panel_type: 'project' }); - expect(wrapper.vm.supportsPins).toBe(true); - }); - - it('is true for the group sidebar', () => { - createWrapper({ ...sidebarData, panel_type: 'group' }); - expect(wrapper.vm.supportsPins).toBe(true); - }); - - it('is false for any other sidebar', () => { - createWrapper({ ...sidebarData, panel_type: 'your_work' }); - expect(wrapper.vm.supportsPins).toEqual(false); + const findNonStaticItemsSection = () => wrapper.findByTestId('non-static-items-section'); + const findNonStaticItems = () => findNonStaticItemsSection().findAllComponents(NavItem); + const findNonStaticSectionItems = () => + findNonStaticItemsSection().findAllComponents(MenuSection); + + describe('Static section', () => { + describe('when the sidebar supports pins', () => { + beforeEach(() => { + createWrapper({ + items: menuItems, + panelType: PANELS_WITH_PINS[0], + }); }); - }); - describe('flatPinnableItems', () => { - it('returns all subitems in a flat array', () => { - createWrapper({ ...sidebarData, current_menu_items: menuItems }); - expect(wrapper.vm.flatPinnableItems).toEqual([ - { id: 21, title: 'Pinned subitem' }, - { id: 41, title: 'Subitem' }, + it('renders static items section', () => { + expect(findStaticItemsSection().exists()).toBe(true); + expect(findStaticItems().wrappers.map((w) => w.props('item').title)).toEqual([ + 'No subitems', + 'Empty subitems array', ]); }); }); - describe('staticItems', () => { - describe('when the sidebar supports pins', () => { - beforeEach(() => { - createWrapper({ - ...sidebarData, - current_menu_items: menuItems, - panel_type: PANELS_WITH_PINS[0], - }); + describe('when the sidebar does not support pins', () => { + beforeEach(() => { + createWrapper({ + items: menuItems, + panelType: 'explore', }); + }); - it('makes everything that has no subitems a static item', () => { - expect(wrapper.vm.staticItems.map((i) => i.title)).toEqual([ - 'No subitems', - 'Empty subitems array', - ]); - }); + it('does not render static items section', () => { + expect(findStaticItemsSection().exists()).toBe(false); }); + }); + }); - describe('when the sidebar does not support pins', () => { - beforeEach(() => { - createWrapper({ - ...sidebarData, - current_menu_items: menuItems, - panel_type: 'explore', - }); - }); + describe('Pinned section', () => { + it('is rendered in a project sidebar', () => { + createWrapper({ panelType: 'project' }); + expect(findPinnedSection().exists()).toBe(true); + }); - it('returns an empty array', () => { - expect(wrapper.vm.staticItems.map((i) => i.title)).toEqual([]); - }); - }); + it('is rendered in a group sidebar', () => { + createWrapper({ panelType: 'group' }); + expect(findPinnedSection().exists()).toBe(true); }); - describe('nonStaticItems', () => { - describe('when the sidebar supports pins', () => { - beforeEach(() => { - createWrapper({ - ...sidebarData, - current_menu_items: menuItems, - panel_type: PANELS_WITH_PINS[0], - }); - }); + it('is not rendered in other sidebars', () => { + createWrapper({ panelType: 'your_work' }); + expect(findPinnedSection().exists()).toBe(false); + }); + }); - it('keeps items that have subitems (aka "sections") as non-static', () => { - expect(wrapper.vm.nonStaticItems.map((i) => i.title)).toEqual([ - 'With subitems', - 'Also with subitems', - ]); + describe('Non static items section', () => { + describe('when the sidebar supports pins', () => { + beforeEach(() => { + createWrapper({ + items: menuItems, + panelType: PANELS_WITH_PINS[0], }); }); - describe('when the sidebar does not support pins', () => { - beforeEach(() => { - createWrapper({ - ...sidebarData, - current_menu_items: menuItems, - panel_type: 'explore', - }); - }); - - it('keeps all items as non-static', () => { - expect(wrapper.vm.nonStaticItems).toEqual(menuItems); - }); + it('keeps items that have subitems (aka "sections") as non-static', () => { + expect(findNonStaticSectionItems().wrappers.map((w) => w.props('item').title)).toEqual([ + 'With subitems', + 'Also with subitems', + ]); }); }); - describe('pinnedItems', () => { - describe('when user has no pinned item ids stored', () => { - beforeEach(() => { - createWrapper({ - ...sidebarData, - current_menu_items: menuItems, - pinned_items: [], - }); - }); - - it('returns an empty array', () => { - expect(wrapper.vm.pinnedItems).toEqual([]); + describe('when the sidebar does not support pins', () => { + beforeEach(() => { + createWrapper({ + items: menuItems, + panelType: 'explore', }); }); - describe('when user has some pinned item ids stored', () => { - beforeEach(() => { - createWrapper({ - ...sidebarData, - current_menu_items: menuItems, - pinned_items: [21], - }); - }); - - it('returns the items matching the pinned ids', () => { - expect(wrapper.vm.pinnedItems).toEqual([{ id: 21, title: 'Pinned subitem' }]); - }); + it('keeps all items as non-static', () => { + expect(findNonStaticSectionItems().length + findNonStaticItems().length).toBe( + menuItems.length, + ); }); }); }); - describe('Menu separators', () => { + describe('Separators', () => { it('should add the separator above pinned section', () => { createWrapper({ - ...sidebarData, - current_menu_items: menuItems, - panel_type: 'project', + items: menuItems, + panelType: 'project', }); expect(findPinnedSection().props('separated')).toBe(true); }); it('should add the separator above main menu items when there is a pinned section', () => { createWrapper({ - ...sidebarData, - current_menu_items: menuItems, - panel_type: PANELS_WITH_PINS[0], + items: menuItems, + panelType: PANELS_WITH_PINS[0], }); expect(findMainMenuSeparator().exists()).toBe(true); }); it('should NOT add the separator above main menu items when there is no pinned section', () => { createWrapper({ - ...sidebarData, - current_menu_items: menuItems, - panel_type: 'explore', + items: menuItems, + panelType: 'explore', }); expect(findMainMenuSeparator().exists()).toBe(false); }); }); - describe('template', () => { + describe('ARIA attributes', () => { it('adds aria-label attribute to nav element', () => { - createWrapper({ ...sidebarData }); - expect(wrapper.find('nav').attributes('aria-label')).toBe(s__('Navigation|Main navigation')); + createWrapper(); + expect(wrapper.find('nav').attributes('aria-label')).toBe('Main navigation'); }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_preparing_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_preparing_spec.js new file mode 100644 index 00000000000..a54591cdb16 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_preparing_spec.js @@ -0,0 +1,29 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; + +import Preparing from '~/vue_merge_request_widget/components/states/mr_widget_preparing.vue'; +import { MR_WIDGET_PREPARING_ASYNCHRONOUSLY } from '~/vue_merge_request_widget/i18n'; + +function createComponent() { + return shallowMount(Preparing); +} + +function findSpinnerIcon(wrapper) { + return wrapper.findComponent(GlLoadingIcon); +} + +describe('Preparing', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + it('should render a spinner', () => { + expect(findSpinnerIcon(wrapper).exists()).toBe(true); + }); + + it('should render the correct text', () => { + expect(wrapper.text()).toBe(MR_WIDGET_PREPARING_ASYNCHRONOUSLY); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js index 656203b1d25..daa45a9e876 100644 --- a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js @@ -3,8 +3,8 @@ import { mount, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -import * as Sentry from '@sentry/browser'; import { createMockSubscription as createMockApolloSubscription } from 'mock-apollo-client'; +import * as Sentry from '@sentry/browser'; import approvedByCurrentUser from 'test_fixtures/graphql/merge_requests/approvals/approvals.query.graphql.json'; import getStateQueryResponse from 'test_fixtures/graphql/merge_requests/get_state.query.graphql.json'; import readyToMergeResponse from 'test_fixtures/graphql/merge_requests/states/ready_to_merge.query.graphql.json'; @@ -26,10 +26,13 @@ import { STATE_QUERY_POLLING_INTERVAL_BACKOFF } from '~/vue_merge_request_widget import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants'; import eventHub from '~/vue_merge_request_widget/event_hub'; import MrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue'; +import Approvals from '~/vue_merge_request_widget/components/approvals/approvals.vue'; +import Preparing from '~/vue_merge_request_widget/components/states/mr_widget_preparing.vue'; import WidgetContainer from '~/vue_merge_request_widget/components/widget/app.vue'; import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue'; import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql'; import getStateQuery from '~/vue_merge_request_widget/queries/get_state.query.graphql'; +import getStateSubscription from '~/vue_merge_request_widget/queries/get_state.subscription.graphql'; import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql'; import approvalsQuery from 'ee_else_ce/vue_merge_request_widget/components/approvals/queries/approvals.query.graphql'; import approvedBySubscription from 'ee_else_ce/vue_merge_request_widget/components/approvals/queries/approvals.subscription.graphql'; @@ -65,13 +68,14 @@ jest.mock('@sentry/browser', () => ({ Vue.use(VueApollo); describe('MrWidgetOptions', () => { - let mockedApprovalsSubscription; let stateQueryHandler; let queryResponse; let wrapper; let mock; const COLLABORATION_MESSAGE = 'Members who can merge are allowed to add commits'; + const findApprovalsWidget = () => wrapper.findComponent(Approvals); + const findPreparingWidget = () => wrapper.findComponent(Preparing); const findWidgetContainer = () => wrapper.findComponent(WidgetContainer); const findExtensionToggleButton = () => wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]'); @@ -97,7 +101,7 @@ describe('MrWidgetOptions', () => { }); const createComponent = (mrData = mockData, options = {}, data = {}, fullMount = true) => { - mockedApprovalsSubscription = createMockApolloSubscription(); + const mockedApprovalsSubscription = createMockApolloSubscription(); queryResponse = { data: { project: { @@ -105,6 +109,9 @@ describe('MrWidgetOptions', () => { mergeRequest: { ...getStateQueryResponse.data.project.mergeRequest, mergeError: mrData.mergeError || null, + detailedMergeStatus: + mrData.detailedMergeStatus || + getStateQueryResponse.data.project.mergeRequest.detailedMergeStatus, }, }, }, @@ -128,7 +135,10 @@ describe('MrWidgetOptions', () => { ], ...(options.apolloMock || []), ]; - const subscriptionHandlers = [[approvedBySubscription, () => mockedApprovalsSubscription]]; + const subscriptionHandlers = [ + [approvedBySubscription, () => mockedApprovalsSubscription], + ...(options.apolloSubscriptions || []), + ]; const apolloProvider = createMockApollo(queryHandlers); subscriptionHandlers.forEach(([query, stream]) => { @@ -1275,4 +1285,86 @@ describe('MrWidgetOptions', () => { }); }); }); + + describe('async preparation for a newly opened MR', () => { + beforeEach(() => { + mock + .onGet(mockData.merge_request_widget_path) + .reply(() => [HTTP_STATUS_OK, { ...mockData, state: 'opened' }]); + }); + + it('does not render the Preparing state component by default', async () => { + await createComponent(); + + expect(findApprovalsWidget().exists()).toBe(true); + expect(findPreparingWidget().exists()).toBe(false); + }); + + it('renders the Preparing state component when the MR state is initially "preparing"', async () => { + await createComponent({ + ...mockData, + state: 'opened', + detailedMergeStatus: 'PREPARING', + }); + + expect(findApprovalsWidget().exists()).toBe(false); + expect(findPreparingWidget().exists()).toBe(true); + }); + + describe('when the MR is updated by observing its status', () => { + let stateSubscription; + + beforeEach(() => { + window.gon.features.realtimeMrStatusChange = true; + stateSubscription = createMockApolloSubscription(); + }); + + it("shows the Preparing widget when the MR reports it's not ready yet", async () => { + await createComponent( + { + ...mockData, + state: 'opened', + detailedMergeStatus: 'PREPARING', + }, + { + apolloSubscriptions: [[getStateSubscription, () => stateSubscription]], + }, + {}, + false, + ); + + expect(wrapper.html()).toContain('mr-widget-preparing-stub'); + }); + + it('removes the Preparing widget when the MR indicates it has been prepared', async () => { + await createComponent( + { + ...mockData, + state: 'opened', + detailedMergeStatus: 'PREPARING', + }, + { + apolloSubscriptions: [[getStateSubscription, () => stateSubscription]], + }, + {}, + false, + ); + + expect(wrapper.html()).toContain('mr-widget-preparing-stub'); + + stateSubscription.next({ + data: { + mergeRequestMergeStatusUpdated: { + preparedAt: 'non-null value', + }, + }, + }); + + // Wait for batched DOM updates + await nextTick(); + + expect(wrapper.html()).not.toContain('mr-widget-preparing-stub'); + }); + }); + }); }); diff --git a/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js b/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js index a6288b9c725..ca5c9084a62 100644 --- a/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js +++ b/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js @@ -16,10 +16,14 @@ describe('getStateKey', () => { commitsCount: 2, hasConflicts: false, draft: false, - detailedMergeStatus: null, + detailedMergeStatus: 'PREPARING', }; const bound = getStateKey.bind(context); + expect(bound()).toEqual('preparing'); + + context.detailedMergeStatus = null; + expect(bound()).toEqual('checking'); context.detailedMergeStatus = 'MERGEABLE'; diff --git a/spec/frontend/vue_shared/components/source_viewer/components/__snapshots__/chunk_spec.js.snap b/spec/frontend/vue_shared/components/source_viewer/components/__snapshots__/chunk_new_spec.js.snap index 26c9a6f8d5a..26c9a6f8d5a 100644 --- a/spec/frontend/vue_shared/components/source_viewer/components/__snapshots__/chunk_spec.js.snap +++ b/spec/frontend/vue_shared/components/source_viewer/components/__snapshots__/chunk_new_spec.js.snap diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_deprecated_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_deprecated_spec.js deleted file mode 100644 index 395ba92d4c6..00000000000 --- a/spec/frontend/vue_shared/components/source_viewer/components/chunk_deprecated_spec.js +++ /dev/null @@ -1,121 +0,0 @@ -import { nextTick } from 'vue'; -import { GlIntersectionObserver } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import Chunk from '~/vue_shared/components/source_viewer/components/chunk_deprecated.vue'; -import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue'; -import LineHighlighter from '~/blob/line_highlighter'; - -const lineHighlighter = new LineHighlighter(); -jest.mock('~/blob/line_highlighter', () => - jest.fn().mockReturnValue({ - highlightHash: jest.fn(), - }), -); - -const DEFAULT_PROPS = { - chunkIndex: 2, - isHighlighted: false, - content: '// Line 1 content \n // Line 2 content', - startingFrom: 140, - totalLines: 50, - language: 'javascript', - blamePath: 'blame/file.js', -}; - -const hash = '#L142'; - -describe('Chunk component', () => { - let wrapper; - let idleCallbackSpy; - - const createComponent = (props = {}) => { - wrapper = shallowMountExtended(Chunk, { - mocks: { $route: { hash } }, - 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(() => { - idleCallbackSpy = jest.spyOn(window, 'requestIdleCallback').mockImplementation((fn) => fn()); - createComponent(); - }); - - 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 register window.requestIdleCallback if isFirstChunk prop is true, renders lines immediately', () => { - jest.clearAllMocks(); - createComponent({ isFirstChunk: true }); - - expect(window.requestIdleCallback).not.toHaveBeenCalled(); - expect(findContent().exists()).toBe(true); - }); - - it('does not render a Chunk Line component if isHighlighted is false', () => { - expect(findChunkLines().length).toBe(0); - }); - - it('does not render simplified line numbers and content if browser is not in idle state', () => { - idleCallbackSpy.mockRestore(); - createComponent(); - - expect(findLineNumbers()).toHaveLength(0); - expect(findContent().exists()).toBe(false); - }); - - it('renders simplified line numbers and content if isHighlighted is false', () => { - expect(findLineNumbers().length).toBe(DEFAULT_PROPS.totalLines); - - expect(findLineNumbers().at(0).attributes('id')).toBe(`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, - blamePath: DEFAULT_PROPS.blamePath, - }); - }); - - it('does not scroll to route hash if last chunk is not loaded', () => { - expect(LineHighlighter).not.toHaveBeenCalled(); - }); - - it('scrolls to route hash if last chunk is loaded', async () => { - createComponent({ totalChunks: DEFAULT_PROPS.chunkIndex + 1 }); - await nextTick(); - expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' }); - expect(lineHighlighter.highlightHash).toHaveBeenCalledWith(hash); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_new_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_new_spec.js new file mode 100644 index 00000000000..919abc26e05 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_new_spec.js @@ -0,0 +1,84 @@ +import { nextTick } from 'vue'; +import { GlIntersectionObserver } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Chunk from '~/vue_shared/components/source_viewer/components/chunk_new.vue'; +import { CHUNK_1, CHUNK_2 } from '../mock_data'; + +describe('Chunk component', () => { + let wrapper; + let idleCallbackSpy; + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(Chunk, { + propsData: { ...CHUNK_1, ...props }, + }); + }; + + const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); + const findLineNumbers = () => wrapper.findAllByTestId('line-numbers'); + const findContent = () => wrapper.findByTestId('content'); + + beforeEach(() => { + idleCallbackSpy = jest.spyOn(window, 'requestIdleCallback').mockImplementation((fn) => fn()); + createComponent(); + }); + + describe('Intersection observer', () => { + it('renders an Intersection observer component', () => { + expect(findIntersectionObserver().exists()).toBe(true); + }); + + it('renders highlighted content if appear event is emitted', async () => { + createComponent({ chunkIndex: 1, isHighlighted: false }); + findIntersectionObserver().vm.$emit('appear'); + + await nextTick(); + + expect(findContent().exists()).toBe(true); + }); + }); + + describe('rendering', () => { + it('does not register window.requestIdleCallback for the first chunk, renders content immediately', () => { + jest.clearAllMocks(); + + expect(window.requestIdleCallback).not.toHaveBeenCalled(); + expect(findContent().text()).toBe(CHUNK_1.highlightedContent); + }); + + it('does not render content if browser is not in idle state', () => { + idleCallbackSpy.mockRestore(); + createComponent({ chunkIndex: 1, ...CHUNK_2 }); + + expect(findLineNumbers()).toHaveLength(0); + expect(findContent().exists()).toBe(false); + }); + + describe('isHighlighted is false', () => { + beforeEach(() => createComponent(CHUNK_2)); + + it('does not render line numbers', () => { + expect(findLineNumbers()).toHaveLength(0); + }); + + it('renders raw content', () => { + expect(findContent().text()).toBe(CHUNK_2.rawContent); + }); + }); + + describe('isHighlighted is true', () => { + beforeEach(() => createComponent({ ...CHUNK_2, isHighlighted: true })); + + it('renders line numbers', () => { + expect(findLineNumbers()).toHaveLength(CHUNK_2.totalLines); + + // Opted for a snapshot test here since the output is simple and verifies native HTML elements + expect(findLineNumbers().at(0).element).toMatchSnapshot(); + }); + + it('renders highlighted content', () => { + expect(findContent().text()).toBe(CHUNK_2.highlightedContent); + }); + }); + }); +}); 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 index ff50326917f..9e43aa1d707 100644 --- a/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js @@ -2,7 +2,27 @@ import { nextTick } from 'vue'; 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 { CHUNK_1, CHUNK_2 } from '../mock_data'; +import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue'; +import LineHighlighter from '~/blob/line_highlighter'; + +const lineHighlighter = new LineHighlighter(); +jest.mock('~/blob/line_highlighter', () => + jest.fn().mockReturnValue({ + highlightHash: jest.fn(), + }), +); + +const DEFAULT_PROPS = { + chunkIndex: 2, + isHighlighted: false, + content: '// Line 1 content \n // Line 2 content', + startingFrom: 140, + totalLines: 50, + language: 'javascript', + blamePath: 'blame/file.js', +}; + +const hash = '#L142'; describe('Chunk component', () => { let wrapper; @@ -10,12 +30,14 @@ describe('Chunk component', () => { const createComponent = (props = {}) => { wrapper = shallowMountExtended(Chunk, { - propsData: { ...CHUNK_1, ...props }, + mocks: { $route: { hash } }, + propsData: { ...DEFAULT_PROPS, ...props }, }); }; const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); - const findLineNumbers = () => wrapper.findAllByTestId('line-numbers'); + const findChunkLines = () => wrapper.findAllComponents(ChunkLine); + const findLineNumbers = () => wrapper.findAllByTestId('line-number'); const findContent = () => wrapper.findByTestId('content'); beforeEach(() => { @@ -28,57 +50,72 @@ describe('Chunk component', () => { expect(findIntersectionObserver().exists()).toBe(true); }); - it('renders highlighted content if appear event is emitted', async () => { - createComponent({ chunkIndex: 1, isHighlighted: false }); + it('emits an appear event when intersection-observer appears', () => { findIntersectionObserver().vm.$emit('appear'); - await nextTick(); + expect(wrapper.emitted('appear')).toEqual([[DEFAULT_PROPS.chunkIndex]]); + }); - expect(findContent().exists()).toBe(true); + 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 register window.requestIdleCallback for the first chunk, renders content immediately', () => { + it('does not register window.requestIdleCallback if isFirstChunk prop is true, renders lines immediately', () => { jest.clearAllMocks(); + createComponent({ isFirstChunk: true }); expect(window.requestIdleCallback).not.toHaveBeenCalled(); - expect(findContent().text()).toBe(CHUNK_1.highlightedContent); + expect(findContent().exists()).toBe(true); + }); + + it('does not render a Chunk Line component if isHighlighted is false', () => { + expect(findChunkLines().length).toBe(0); }); - it('does not render content if browser is not in idle state', () => { + it('does not render simplified line numbers and content if browser is not in idle state', () => { idleCallbackSpy.mockRestore(); - createComponent({ chunkIndex: 1, ...CHUNK_2 }); + createComponent(); expect(findLineNumbers()).toHaveLength(0); expect(findContent().exists()).toBe(false); }); - describe('isHighlighted is false', () => { - beforeEach(() => createComponent(CHUNK_2)); + it('renders simplified line numbers and content if isHighlighted is false', () => { + expect(findLineNumbers().length).toBe(DEFAULT_PROPS.totalLines); - it('does not render line numbers', () => { - expect(findLineNumbers()).toHaveLength(0); - }); + expect(findLineNumbers().at(0).attributes('id')).toBe(`L${DEFAULT_PROPS.startingFrom + 1}`); - it('renders raw content', () => { - expect(findContent().text()).toBe(CHUNK_2.rawContent); - }); + expect(findContent().text()).toBe(DEFAULT_PROPS.content); }); - describe('isHighlighted is true', () => { - beforeEach(() => createComponent({ ...CHUNK_2, isHighlighted: true })); + it('renders Chunk Line components if isHighlighted is true', () => { + const splitContent = DEFAULT_PROPS.content.split('\n'); + createComponent({ isHighlighted: true }); - it('renders line numbers', () => { - expect(findLineNumbers()).toHaveLength(CHUNK_2.totalLines); + expect(findChunkLines().length).toBe(splitContent.length); - // Opted for a snapshot test here since the output is simple and verifies native HTML elements - expect(findLineNumbers().at(0).element).toMatchSnapshot(); + expect(findChunkLines().at(0).props()).toMatchObject({ + number: DEFAULT_PROPS.startingFrom + 1, + content: splitContent[0], + language: DEFAULT_PROPS.language, + blamePath: DEFAULT_PROPS.blamePath, }); + }); - it('renders highlighted content', () => { - expect(findContent().text()).toBe(CHUNK_2.highlightedContent); - }); + it('does not scroll to route hash if last chunk is not loaded', () => { + expect(LineHighlighter).not.toHaveBeenCalled(); + }); + + it('scrolls to route hash if last chunk is loaded', async () => { + createComponent({ totalChunks: DEFAULT_PROPS.chunkIndex + 1 }); + await nextTick(); + expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' }); + expect(lineHighlighter.highlightHash).toHaveBeenCalledWith(hash); }); }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_deprecated_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_deprecated_spec.js deleted file mode 100644 index e46c30d7c49..00000000000 --- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_deprecated_spec.js +++ /dev/null @@ -1,192 +0,0 @@ -import hljs from 'highlight.js/lib/core'; -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_deprecated.vue'; -import { registerPlugins } from '~/vue_shared/components/source_viewer/plugins/index'; -import Chunk from '~/vue_shared/components/source_viewer/components/chunk_deprecated.vue'; -import { - EVENT_ACTION, - EVENT_LABEL_VIEWER, - EVENT_LABEL_FALLBACK, - ROUGE_TO_HLJS_LANGUAGE_MAP, - LINES_PER_CHUNK, - LEGACY_FALLBACKS, - CODEOWNERS_FILE_NAME, - CODEOWNERS_LANGUAGE, -} from '~/vue_shared/components/source_viewer/constants'; -import waitForPromises from 'helpers/wait_for_promises'; -import LineHighlighter from '~/blob/line_highlighter'; -import eventHub from '~/notes/event_hub'; -import Tracking from '~/tracking'; - -jest.mock('~/blob/line_highlighter'); -jest.mock('highlight.js/lib/core'); -jest.mock('~/vue_shared/components/source_viewer/plugins/index'); -Vue.use(VueRouter); -const router = new VueRouter(); - -const generateContent = (content, totalLines = 1, delimiter = '\n') => { - let generatedContent = ''; - for (let i = 0; i < totalLines; i += 1) { - generatedContent += `Line: ${i + 1} = ${content}${delimiter}`; - } - return generatedContent; -}; - -const execImmediately = (callback) => callback(); - -describe('Source Viewer component', () => { - let wrapper; - const language = 'docker'; - const mappedLanguage = ROUGE_TO_HLJS_LANGUAGE_MAP[language]; - const chunk1 = generateContent('// Some source code 1', 70); - const chunk2 = generateContent('// Some source code 2', 70); - const chunk3 = generateContent('// Some source code 3', 70, '\r\n'); - const chunk3Result = generateContent('// Some source code 3', 70, '\n'); - const content = chunk1 + chunk2 + chunk3; - const path = 'some/path.js'; - const blamePath = 'some/blame/path.js'; - const fileType = 'javascript'; - const DEFAULT_BLOB_DATA = { language, rawTextBlob: content, path, blamePath, fileType }; - const highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`; - - const createComponent = async (blob = {}) => { - wrapper = shallowMountExtended(SourceViewer, { - router, - propsData: { blob: { ...DEFAULT_BLOB_DATA, ...blob } }, - }); - await waitForPromises(); - }; - - const findChunks = () => wrapper.findAllComponents(Chunk); - - beforeEach(() => { - hljs.highlight.mockImplementation(() => ({ value: highlightedContent })); - hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent })); - jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately); - jest.spyOn(eventHub, '$emit'); - jest.spyOn(Tracking, 'event'); - - return createComponent(); - }); - - describe('event tracking', () => { - it('fires a tracking event when the component is created', () => { - const eventData = { label: EVENT_LABEL_VIEWER, property: language }; - expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); - }); - - it('does not emit an error event when the language is supported', () => { - expect(wrapper.emitted('error')).toBeUndefined(); - }); - - it('fires a tracking event and emits an error when the language is not supported', () => { - const unsupportedLanguage = 'apex'; - const eventData = { label: EVENT_LABEL_FALLBACK, property: unsupportedLanguage }; - createComponent({ language: unsupportedLanguage }); - - expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); - expect(wrapper.emitted('error')).toHaveLength(1); - }); - }); - - describe('legacy fallbacks', () => { - it.each(LEGACY_FALLBACKS)( - 'tracks a fallback event and emits an error when viewing %s files', - (fallbackLanguage) => { - const eventData = { label: EVENT_LABEL_FALLBACK, property: fallbackLanguage }; - createComponent({ language: fallbackLanguage }); - - expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); - expect(wrapper.emitted('error')).toHaveLength(1); - }, - ); - }); - - describe('highlight.js', () => { - beforeEach(() => createComponent({ language: mappedLanguage })); - - it('registers our plugins for Highlight.js', () => { - expect(registerPlugins).toHaveBeenCalledWith(hljs, fileType, content); - }); - - it('registers the language definition', async () => { - const languageDefinition = await import(`highlight.js/lib/languages/${mappedLanguage}`); - - expect(hljs.registerLanguage).toHaveBeenCalledWith( - mappedLanguage, - languageDefinition.default, - ); - }); - - it('registers json language definition if fileType is package_json', async () => { - await createComponent({ language: 'json', fileType: 'package_json' }); - const languageDefinition = await import(`highlight.js/lib/languages/json`); - - expect(hljs.registerLanguage).toHaveBeenCalledWith('json', languageDefinition.default); - }); - - it('correctly maps languages starting with uppercase', async () => { - await createComponent({ language: 'Ruby' }); - const languageDefinition = await import(`highlight.js/lib/languages/ruby`); - - expect(hljs.registerLanguage).toHaveBeenCalledWith('ruby', languageDefinition.default); - }); - - it('registers codeowners language definition if file name is CODEOWNERS', async () => { - await createComponent({ name: CODEOWNERS_FILE_NAME }); - const languageDefinition = await import( - '~/vue_shared/components/source_viewer/languages/codeowners' - ); - - expect(hljs.registerLanguage).toHaveBeenCalledWith( - CODEOWNERS_LANGUAGE, - languageDefinition.default, - ); - }); - - it('highlights the first chunk', () => { - expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage }); - expect(findChunks().at(0).props('isFirstChunk')).toBe(true); - }); - - 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(chunk1.trim()); - }); - }); - }); - - describe('rendering', () => { - it.each` - chunkIndex | chunkContent | totalChunks - ${0} | ${chunk1} | ${0} - ${1} | ${chunk2} | ${3} - ${2} | ${chunk3Result} | ${3} - `('renders chunk $chunkIndex', ({ chunkIndex, chunkContent, totalChunks }) => { - const chunk = findChunks().at(chunkIndex); - - expect(chunk.props('content')).toContain(chunkContent.trim()); - - expect(chunk.props()).toMatchObject({ - totalLines: LINES_PER_CHUNK, - startingFrom: LINES_PER_CHUNK * chunkIndex, - totalChunks, - }); - }); - - it('emits showBlobInteractionZones on the eventHub when chunk appears', () => { - findChunks().at(0).vm.$emit('appear'); - expect(eventHub.$emit).toHaveBeenCalledWith('showBlobInteractionZones', path); - }); - }); - - describe('LineHighlighter', () => { - it('instantiates the lineHighlighter class', () => { - expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js new file mode 100644 index 00000000000..715234e56fd --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js @@ -0,0 +1,45 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer_new.vue'; +import Chunk from '~/vue_shared/components/source_viewer/components/chunk_new.vue'; +import { EVENT_ACTION, EVENT_LABEL_VIEWER } from '~/vue_shared/components/source_viewer/constants'; +import Tracking from '~/tracking'; +import addBlobLinksTracking from '~/blob/blob_links_tracking'; +import { BLOB_DATA_MOCK, CHUNK_1, CHUNK_2, LANGUAGE_MOCK } from './mock_data'; + +jest.mock('~/blob/blob_links_tracking'); + +describe('Source Viewer component', () => { + let wrapper; + const CHUNKS_MOCK = [CHUNK_1, CHUNK_2]; + + const createComponent = () => { + wrapper = shallowMountExtended(SourceViewer, { + propsData: { blob: BLOB_DATA_MOCK, chunks: CHUNKS_MOCK }, + }); + }; + + const findChunks = () => wrapper.findAllComponents(Chunk); + + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + return createComponent(); + }); + + describe('event tracking', () => { + it('fires a tracking event when the component is created', () => { + const eventData = { label: EVENT_LABEL_VIEWER, property: LANGUAGE_MOCK }; + expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); + }); + + it('adds blob links tracking', () => { + expect(addBlobLinksTracking).toHaveBeenCalled(); + }); + }); + + describe('rendering', () => { + it('renders a Chunk component for each chunk', () => { + expect(findChunks().at(0).props()).toMatchObject(CHUNK_1); + expect(findChunks().at(1).props()).toMatchObject(CHUNK_2); + }); + }); +}); 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 46b582c3668..6b1d65c5a6a 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,45 +1,192 @@ +import hljs from 'highlight.js/lib/core'; +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 { registerPlugins } from '~/vue_shared/components/source_viewer/plugins/index'; import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue'; -import { EVENT_ACTION, EVENT_LABEL_VIEWER } from '~/vue_shared/components/source_viewer/constants'; +import { + EVENT_ACTION, + EVENT_LABEL_VIEWER, + EVENT_LABEL_FALLBACK, + ROUGE_TO_HLJS_LANGUAGE_MAP, + LINES_PER_CHUNK, + LEGACY_FALLBACKS, + CODEOWNERS_FILE_NAME, + CODEOWNERS_LANGUAGE, +} from '~/vue_shared/components/source_viewer/constants'; +import waitForPromises from 'helpers/wait_for_promises'; +import LineHighlighter from '~/blob/line_highlighter'; +import eventHub from '~/notes/event_hub'; import Tracking from '~/tracking'; -import addBlobLinksTracking from '~/blob/blob_links_tracking'; -import { BLOB_DATA_MOCK, CHUNK_1, CHUNK_2, LANGUAGE_MOCK } from './mock_data'; -jest.mock('~/blob/blob_links_tracking'); +jest.mock('~/blob/line_highlighter'); +jest.mock('highlight.js/lib/core'); +jest.mock('~/vue_shared/components/source_viewer/plugins/index'); +Vue.use(VueRouter); +const router = new VueRouter(); + +const generateContent = (content, totalLines = 1, delimiter = '\n') => { + let generatedContent = ''; + for (let i = 0; i < totalLines; i += 1) { + generatedContent += `Line: ${i + 1} = ${content}${delimiter}`; + } + return generatedContent; +}; + +const execImmediately = (callback) => callback(); describe('Source Viewer component', () => { let wrapper; - const CHUNKS_MOCK = [CHUNK_1, CHUNK_2]; + const language = 'docker'; + const mappedLanguage = ROUGE_TO_HLJS_LANGUAGE_MAP[language]; + const chunk1 = generateContent('// Some source code 1', 70); + const chunk2 = generateContent('// Some source code 2', 70); + const chunk3 = generateContent('// Some source code 3', 70, '\r\n'); + const chunk3Result = generateContent('// Some source code 3', 70, '\n'); + const content = chunk1 + chunk2 + chunk3; + const path = 'some/path.js'; + const blamePath = 'some/blame/path.js'; + const fileType = 'javascript'; + const DEFAULT_BLOB_DATA = { language, rawTextBlob: content, path, blamePath, fileType }; + const highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`; - const createComponent = () => { + const createComponent = async (blob = {}) => { wrapper = shallowMountExtended(SourceViewer, { - propsData: { blob: BLOB_DATA_MOCK, chunks: CHUNKS_MOCK }, + router, + propsData: { blob: { ...DEFAULT_BLOB_DATA, ...blob } }, }); + await waitForPromises(); }; const findChunks = () => wrapper.findAllComponents(Chunk); beforeEach(() => { + hljs.highlight.mockImplementation(() => ({ value: highlightedContent })); + hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent })); + jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately); + jest.spyOn(eventHub, '$emit'); jest.spyOn(Tracking, 'event'); + return createComponent(); }); describe('event tracking', () => { it('fires a tracking event when the component is created', () => { - const eventData = { label: EVENT_LABEL_VIEWER, property: LANGUAGE_MOCK }; + const eventData = { label: EVENT_LABEL_VIEWER, property: language }; + expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); + }); + + it('does not emit an error event when the language is supported', () => { + expect(wrapper.emitted('error')).toBeUndefined(); + }); + + it('fires a tracking event and emits an error when the language is not supported', () => { + const unsupportedLanguage = 'apex'; + const eventData = { label: EVENT_LABEL_FALLBACK, property: unsupportedLanguage }; + createComponent({ language: unsupportedLanguage }); + expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); + expect(wrapper.emitted('error')).toHaveLength(1); + }); + }); + + describe('legacy fallbacks', () => { + it.each(LEGACY_FALLBACKS)( + 'tracks a fallback event and emits an error when viewing %s files', + (fallbackLanguage) => { + const eventData = { label: EVENT_LABEL_FALLBACK, property: fallbackLanguage }; + createComponent({ language: fallbackLanguage }); + + expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); + expect(wrapper.emitted('error')).toHaveLength(1); + }, + ); + }); + + describe('highlight.js', () => { + beforeEach(() => createComponent({ language: mappedLanguage })); + + it('registers our plugins for Highlight.js', () => { + expect(registerPlugins).toHaveBeenCalledWith(hljs, fileType, content); + }); + + it('registers the language definition', async () => { + const languageDefinition = await import(`highlight.js/lib/languages/${mappedLanguage}`); + + expect(hljs.registerLanguage).toHaveBeenCalledWith( + mappedLanguage, + languageDefinition.default, + ); }); - it('adds blob links tracking', () => { - expect(addBlobLinksTracking).toHaveBeenCalled(); + it('registers json language definition if fileType is package_json', async () => { + await createComponent({ language: 'json', fileType: 'package_json' }); + const languageDefinition = await import(`highlight.js/lib/languages/json`); + + expect(hljs.registerLanguage).toHaveBeenCalledWith('json', languageDefinition.default); + }); + + it('correctly maps languages starting with uppercase', async () => { + await createComponent({ language: 'Ruby' }); + const languageDefinition = await import(`highlight.js/lib/languages/ruby`); + + expect(hljs.registerLanguage).toHaveBeenCalledWith('ruby', languageDefinition.default); + }); + + it('registers codeowners language definition if file name is CODEOWNERS', async () => { + await createComponent({ name: CODEOWNERS_FILE_NAME }); + const languageDefinition = await import( + '~/vue_shared/components/source_viewer/languages/codeowners' + ); + + expect(hljs.registerLanguage).toHaveBeenCalledWith( + CODEOWNERS_LANGUAGE, + languageDefinition.default, + ); + }); + + it('highlights the first chunk', () => { + expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage }); + expect(findChunks().at(0).props('isFirstChunk')).toBe(true); + }); + + 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(chunk1.trim()); + }); }); }); describe('rendering', () => { - it('renders a Chunk component for each chunk', () => { - expect(findChunks().at(0).props()).toMatchObject(CHUNK_1); - expect(findChunks().at(1).props()).toMatchObject(CHUNK_2); + it.each` + chunkIndex | chunkContent | totalChunks + ${0} | ${chunk1} | ${0} + ${1} | ${chunk2} | ${3} + ${2} | ${chunk3Result} | ${3} + `('renders chunk $chunkIndex', ({ chunkIndex, chunkContent, totalChunks }) => { + const chunk = findChunks().at(chunkIndex); + + expect(chunk.props('content')).toContain(chunkContent.trim()); + + expect(chunk.props()).toMatchObject({ + totalLines: LINES_PER_CHUNK, + startingFrom: LINES_PER_CHUNK * chunkIndex, + totalChunks, + }); + }); + + it('emits showBlobInteractionZones on the eventHub when chunk appears', () => { + findChunks().at(0).vm.$emit('appear'); + expect(eventHub.$emit).toHaveBeenCalledWith('showBlobInteractionZones', path); + }); + }); + + describe('LineHighlighter', () => { + it('instantiates the lineHighlighter class', () => { + expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' }); }); }); }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js index 7acbd769b6e..c3a98dcffb1 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js @@ -4,7 +4,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import setWindowLocation from 'helpers/set_window_location_helper'; -import { stubComponent } from 'helpers/stub_component'; +import { RENDER_ALL_SLOTS_TEMPLATE, stubComponent } from 'helpers/stub_component'; import issueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql'; import { resolvers } from '~/graphql_shared/issuable_client'; import WidgetWrapper from '~/work_items/components/widget_wrapper.vue'; @@ -68,11 +68,12 @@ describe('WorkItemLinks', () => { show: showModal, }, }), + WidgetWrapper: stubComponent(WidgetWrapper, { + template: RENDER_ALL_SLOTS_TEMPLATE, + }), }, }); - wrapper.vm.$refs.wrapper.show = jest.fn(); - await waitForPromises(); }; diff --git a/spec/support/shared_examples/features/variable_list_shared_examples.rb b/spec/support/shared_examples/features/variable_list_shared_examples.rb index c75f437a313..3a91b798bbd 100644 --- a/spec/support/shared_examples/features/variable_list_shared_examples.rb +++ b/spec/support/shared_examples/features/variable_list_shared_examples.rb @@ -170,15 +170,13 @@ RSpec.shared_examples 'variable list' do expect(find('[data-testid="alert-danger"]').text).to have_content('(key) has already been taken') end - it 'prevents a variable to be added if no values are provided when a variable is set to masked' do + it 'allows variable to be added even if no value is provided' do click_button('Add variable') page.within('#add-ci-variable') do find('[data-testid="pipeline-form-ci-variable-key"] input').set('empty_mask_key') - find('[data-testid="ci-variable-protected-checkbox"]').click - find('[data-testid="ci-variable-masked-checkbox"]').click - expect(find_button('Add variable', disabled: true)).to be_present + expect(find_button('Add variable', disabled: false)).to be_present end end |