diff options
Diffstat (limited to 'spec')
20 files changed, 684 insertions, 242 deletions
diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index c098ea71f7a..d0aff6e282a 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -473,28 +473,6 @@ RSpec.describe ProjectsController do end end end - - context 'with new_project_sast_enabled', :experiment do - let(:params) do - { - path: 'foo', - description: 'bar', - namespace_id: user.namespace.id, - initialize_with_sast: '1' - } - end - - it 'tracks an event on project creation' do - expect(experiment(:new_project_sast_enabled)).to track(:created, - property: 'blank', - checked: true, - project: an_instance_of(Project), - namespace: user.namespace - ).on_next_instance.with_context(user: user) - - post :create, params: { project: params } - end - end end describe 'GET edit' do diff --git a/spec/experiments/new_project_sast_enabled_experiment_spec.rb b/spec/experiments/new_project_sast_enabled_experiment_spec.rb deleted file mode 100644 index 041e5dfa469..00000000000 --- a/spec/experiments/new_project_sast_enabled_experiment_spec.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe NewProjectSastEnabledExperiment do - it "defines the expected behaviors and variants" do - expect(subject.variant_names).to match_array([ - :candidate, - :free_indicator, - :unchecked_candidate, - :unchecked_free_indicator - ]) - end - - it "publishes to the database" do - expect(subject).to receive(:publish_to_database) - - subject.publish - end -end diff --git a/spec/features/admin/admin_dev_ops_report_spec.rb b/spec/features/admin/admin_dev_ops_reports_spec.rb index cee79f8f440..bf32819cb52 100644 --- a/spec/features/admin/admin_dev_ops_report_spec.rb +++ b/spec/features/admin/admin_dev_ops_reports_spec.rb @@ -15,7 +15,7 @@ RSpec.describe 'DevOps Report page', :js do end it 'has dismissable intro callout' do - visit admin_dev_ops_report_path + visit admin_dev_ops_reports_path expect(page).to have_content 'Introducing Your DevOps Report' @@ -32,13 +32,13 @@ RSpec.describe 'DevOps Report page', :js do end it 'shows empty state' do - visit admin_dev_ops_report_path + visit admin_dev_ops_reports_path expect(page).to have_text('Service ping is off') end it 'hides the intro callout' do - visit admin_dev_ops_report_path + visit admin_dev_ops_reports_path expect(page).not_to have_content 'Introducing Your DevOps Report' end @@ -48,7 +48,7 @@ RSpec.describe 'DevOps Report page', :js do it 'shows empty state' do stub_application_setting(usage_ping_enabled: true) - visit admin_dev_ops_report_path + visit admin_dev_ops_reports_path expect(page).to have_content('Data is still calculating') end @@ -59,7 +59,7 @@ RSpec.describe 'DevOps Report page', :js do stub_application_setting(usage_ping_enabled: true) create(:dev_ops_report_metric) - visit admin_dev_ops_report_path + visit admin_dev_ops_reports_path expect(page).to have_selector('[data-testid="devops-score-app"]') end diff --git a/spec/features/projects/user_creates_project_spec.rb b/spec/features/projects/user_creates_project_spec.rb index 6491a7425f7..84977b6c962 100644 --- a/spec/features/projects/user_creates_project_spec.rb +++ b/spec/features/projects/user_creates_project_spec.rb @@ -33,29 +33,6 @@ RSpec.describe 'User creates a project', :js do end it 'creates a new project that is not blank' do - stub_experiments(new_project_sast_enabled: 'candidate') - - visit(new_project_path) - - click_link 'Create blank project' - fill_in(:project_name, with: 'With initial commits') - - expect(page).to have_checked_field 'Initialize repository with a README' - expect(page).to have_checked_field 'Enable Static Application Security Testing (SAST)' - - click_button('Create project') - - project = Project.last - - expect(page).to have_current_path(project_path(project), ignore_query: true) - expect(page).to have_content('With initial commits') - expect(page).to have_content('Configure SAST in `.gitlab-ci.yml`, creating this file if it does not already exist') - expect(page).to have_content('README.md Initial commit') - end - - it 'allows creating a new project when the new_project_sast_enabled is assigned the unchecked candidate' do - stub_experiments(new_project_sast_enabled: 'unchecked_candidate') - visit(new_project_path) click_link 'Create blank project' 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/table/job_table_app_spec.js b/spec/frontend/jobs/components/table/job_table_app_spec.js index 4d51624dfff..98d8419b26e 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', 'PENDING'); + + 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} + ${'PENDING'} | ${false} + ${'RUNNING'} | ${false} + ${['FAILED', 'SUCCESS', 'CANCELED']} | ${false} + `( + 'with tab scope $scope the filtered search displays $shouldDisplay', + async ({ scope, shouldDisplay }) => { + createComponent(); + + 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/mock_data.js b/spec/frontend/jobs/mock_data.js index 73b9df1853d..b4cc58a04cc 100644 --- a/spec/frontend/jobs/mock_data.js +++ b/spec/frontend/jobs/mock_data.js @@ -1918,3 +1918,5 @@ export const CIJobConnectionExistingCache = { ], statuses: 'PENDING', }; + +export const mockFailedSearchToken = { type: 'status', value: { data: 'FAILED', operator: '=' } }; 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/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/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js index cdaec0a3a8b..313870e8ea1 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -235,9 +235,11 @@ describe('AdminRunnersApp', () => { const mockRunner = runnersData.data.runners.nodes[0]; const { id: graphqlId, shortSha } = mockRunner; 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 +254,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'); 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 9ca99d1109b..7a949cb6505 100644 --- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js @@ -100,6 +100,16 @@ describe('RunnerActionsCell', () => { 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' }; 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/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js index 70e303e8626..6d7ecc4506a 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -193,9 +193,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 +221,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'); diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js index 963577fa763..5787747b00b 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'; @@ -107,6 +107,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 +455,16 @@ 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( + '/help/user/application_security/vulnerabilities/index#enable-security-training-for-vulnerabilities', + ); }); }); diff --git a/spec/routing/admin_routing_spec.rb b/spec/routing/admin_routing_spec.rb index 8c36d7d4668..f48b4de23a2 100644 --- a/spec/routing/admin_routing_spec.rb +++ b/spec/routing/admin_routing_spec.rb @@ -134,10 +134,17 @@ RSpec.describe Admin::HealthCheckController, "routing" do end end -# admin_dev_ops_report GET /admin/dev_ops_report(.:format) admin/dev_ops_report#show +# admin_dev_ops_reports GET /admin/dev_ops_reports(.:format) admin/dev_ops_report#show RSpec.describe Admin::DevOpsReportController, "routing" do it "to #show" do - expect(get("/admin/dev_ops_report")).to route_to('admin/dev_ops_report#show') + expect(get("/admin/dev_ops_reports")).to route_to('admin/dev_ops_report#show') + end + + describe 'admin devops reports' do + include RSpec::Rails::RequestExampleGroup + it 'redirects from /admin/dev_ops_report to /admin/dev_ops_reports' do + expect(get("/admin/dev_ops_report")).to redirect_to(admin_dev_ops_reports_path) + end end end |