diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-03-21 15:08:46 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-03-21 15:08:46 +0300 |
commit | 7f521d27811b472c43203ed3d1bde4460a617f89 (patch) | |
tree | 47f1a10b776991e86c6db002bc6e03e83acc356a /spec | |
parent | 83e3316a189d3b709b23af30647b5f9ea5377bac (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
24 files changed, 1118 insertions, 674 deletions
diff --git a/spec/features/milestones/user_deletes_milestone_spec.rb b/spec/features/milestones/user_deletes_milestone_spec.rb index 141e626c6f3..a7f2457de04 100644 --- a/spec/features/milestones/user_deletes_milestone_spec.rb +++ b/spec/features/milestones/user_deletes_milestone_spec.rb @@ -18,6 +18,7 @@ RSpec.describe "User deletes milestone", :js, feature_category: :team_planning d project.add_developer(user) visit(project_milestones_path(project)) click_link(milestone.title) + click_button("Milestone actions") click_button("Delete") click_button("Delete milestone") @@ -38,6 +39,7 @@ RSpec.describe "User deletes milestone", :js, feature_category: :team_planning d visit(group_milestones_path(group)) click_link(milestone_to_be_deleted.title) + click_button("Milestone actions") click_button("Delete") click_button("Delete milestone") diff --git a/spec/features/projects/pipeline_schedules_spec.rb b/spec/features/projects/pipeline_schedules_spec.rb index acb2af07e50..f7e2adb7829 100644 --- a/spec/features/projects/pipeline_schedules_spec.rb +++ b/spec/features/projects/pipeline_schedules_spec.rb @@ -7,295 +7,394 @@ RSpec.describe 'Pipeline Schedules', :js, feature_category: :projects do let!(:project) { create(:project, :repository) } let!(:pipeline_schedule) { create(:ci_pipeline_schedule, :nightly, project: project) } - let!(:pipeline) { create(:ci_pipeline, pipeline_schedule: pipeline_schedule) } + let!(:pipeline) { create(:ci_pipeline, pipeline_schedule: pipeline_schedule, project: project) } let(:scope) { nil } let!(:user) { create(:user) } + let!(:maintainer) { create(:user) } - before do - stub_feature_flags(pipeline_schedules_vue: false) - end - - context 'logged in as the pipeline schedule owner' do + context 'with pipeline_schedules_vue feature flag turned off' do before do - project.add_developer(user) - pipeline_schedule.update!(owner: user) - gitlab_sign_in(user) + stub_feature_flags(pipeline_schedules_vue: false) end - describe 'GET /projects/pipeline_schedules' do + context 'logged in as the pipeline schedule owner' do before do - visit_pipelines_schedules + project.add_developer(user) + pipeline_schedule.update!(owner: user) + gitlab_sign_in(user) end - it 'edits the pipeline' do - page.within('.pipeline-schedule-table-row') do - click_link 'Edit' + describe 'GET /projects/pipeline_schedules' do + before do + visit_pipelines_schedules end - expect(page).to have_content('Edit Pipeline Schedule') - end - end + it 'edits the pipeline' do + page.within('.pipeline-schedule-table-row') do + click_link 'Edit' + end - describe 'PATCH /projects/pipelines_schedules/:id/edit' do - before do - edit_pipeline_schedule + expect(page).to have_content('Edit Pipeline Schedule') + end end - it 'displays existing properties' do - description = find_field('schedule_description').value - expect(description).to eq('pipeline schedule') - expect(page).to have_button('master') - expect(page).to have_button('Select timezone') - end + describe 'PATCH /projects/pipelines_schedules/:id/edit' do + before do + edit_pipeline_schedule + end - it 'edits the scheduled pipeline' do - fill_in 'schedule_description', with: 'my brand new description' + it 'displays existing properties' do + description = find_field('schedule_description').value + expect(description).to eq('pipeline schedule') + expect(page).to have_button('master') + expect(page).to have_button('Select timezone') + end - save_pipeline_schedule + it 'edits the scheduled pipeline' do + fill_in 'schedule_description', with: 'my brand new description' - expect(page).to have_content('my brand new description') - end + save_pipeline_schedule - context 'when ref is nil' do - before do - pipeline_schedule.update_attribute(:ref, nil) - edit_pipeline_schedule + expect(page).to have_content('my brand new description') end - it 'shows the pipeline schedule with default ref' do - page.within('[data-testid="schedule-target-ref"]') do - expect(first('.gl-button-text').text).to eq('master') + context 'when ref is nil' do + before do + pipeline_schedule.update_attribute(:ref, nil) + edit_pipeline_schedule end - end - end - context 'when ref is empty' do - before do - pipeline_schedule.update_attribute(:ref, '') - edit_pipeline_schedule + it 'shows the pipeline schedule with default ref' do + page.within('[data-testid="schedule-target-ref"]') do + expect(first('.gl-button-text').text).to eq('master') + end + end end - it 'shows the pipeline schedule with default ref' do - page.within('[data-testid="schedule-target-ref"]') do - expect(first('.gl-button-text').text).to eq('master') + context 'when ref is empty' do + before do + pipeline_schedule.update_attribute(:ref, '') + edit_pipeline_schedule + end + + it 'shows the pipeline schedule with default ref' do + page.within('[data-testid="schedule-target-ref"]') do + expect(first('.gl-button-text').text).to eq('master') + end end end end end - end - context 'logged in as a project maintainer' do - before do - project.add_maintainer(user) - gitlab_sign_in(user) - end - - describe 'GET /projects/pipeline_schedules' do + context 'logged in as a project maintainer' do before do - visit_pipelines_schedules + project.add_maintainer(user) + gitlab_sign_in(user) end - describe 'The view' do - it 'displays the required information description' do - page.within('.pipeline-schedule-table-row') do - expect(page).to have_content('pipeline schedule') - expect(find("[data-testid='next-run-cell'] time")['title']) - .to include(pipeline_schedule.real_next_run.strftime('%b %-d, %Y')) - expect(page).to have_link('master') - expect(page).to have_link("##{pipeline.id}") - end + describe 'GET /projects/pipeline_schedules' do + before do + visit_pipelines_schedules end - it 'creates a new scheduled pipeline' do - click_link 'New schedule' + describe 'The view' do + it 'displays the required information description' do + page.within('.pipeline-schedule-table-row') do + expect(page).to have_content('pipeline schedule') + expect(find("[data-testid='next-run-cell'] time")['title']) + .to include(pipeline_schedule.real_next_run.strftime('%b %-d, %Y')) + expect(page).to have_link('master') + expect(page).to have_link("##{pipeline.id}") + end + end - expect(page).to have_content('Schedule a new pipeline') - end + it 'creates a new scheduled pipeline' do + click_link 'New schedule' + + expect(page).to have_content('Schedule a new pipeline') + end - it 'changes ownership of the pipeline' do - click_button 'Take ownership' + it 'changes ownership of the pipeline' do + click_button 'Take ownership' - page.within('#pipeline-take-ownership-modal') do - click_link 'Take ownership' + page.within('#pipeline-take-ownership-modal') do + click_link 'Take ownership' + end + + page.within('.pipeline-schedule-table-row') do + expect(page).not_to have_content('No owner') + expect(page).to have_link('Sidney Jones') + end end - page.within('.pipeline-schedule-table-row') do - expect(page).not_to have_content('No owner') - expect(page).to have_link('Sidney Jones') + it 'deletes the pipeline' do + click_link 'Delete' + + accept_gl_confirm(button_text: 'Delete pipeline schedule') + + expect(page).not_to have_css(".pipeline-schedule-table-row") end end - it 'deletes the pipeline' do - click_link 'Delete' + context 'when ref is nil' do + before do + pipeline_schedule.update_attribute(:ref, nil) + visit_pipelines_schedules + end + + it 'shows a list of the pipeline schedules with empty ref column' do + expect(first('.branch-name-cell').text).to eq('') + end + end - accept_gl_confirm(button_text: 'Delete pipeline schedule') + context 'when ref is empty' do + before do + pipeline_schedule.update_attribute(:ref, '') + visit_pipelines_schedules + end - expect(page).not_to have_css(".pipeline-schedule-table-row") + it 'shows a list of the pipeline schedules with empty ref column' do + expect(first('.branch-name-cell').text).to eq('') + end end end - context 'when ref is nil' do + describe 'POST /projects/pipeline_schedules/new' do before do - pipeline_schedule.update_attribute(:ref, nil) - visit_pipelines_schedules + visit_new_pipeline_schedule + end + + it 'sets defaults for timezone and target branch' do + expect(page).to have_button('master') + expect(page).to have_button('Select timezone') end - it 'shows a list of the pipeline schedules with empty ref column' do - expect(first('.branch-name-cell').text).to eq('') + it 'creates a new scheduled pipeline' do + fill_in_schedule_form + save_pipeline_schedule + + expect(page).to have_content('my fancy description') + end + + it 'prevents an invalid form from being submitted' do + save_pipeline_schedule + + expect(page).to have_content('This field is required') end end - context 'when ref is empty' do + context 'when user creates a new pipeline schedule with variables' do before do - pipeline_schedule.update_attribute(:ref, '') visit_pipelines_schedules + click_link 'New schedule' + fill_in_schedule_form + all('[name="schedule[variables_attributes][][key]"]')[0].set('AAA') + all('[name="schedule[variables_attributes][][secret_value]"]')[0].set('AAA123') + all('[name="schedule[variables_attributes][][key]"]')[1].set('BBB') + all('[name="schedule[variables_attributes][][secret_value]"]')[1].set('BBB123') + save_pipeline_schedule end - it 'shows a list of the pipeline schedules with empty ref column' do - expect(first('.branch-name-cell').text).to eq('') + it 'user sees the new variable in edit window', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/397040' do + find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click + page.within('.ci-variable-list') do + expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('AAA') + expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-value", visible: false).value).to eq('AAA123') + expect(find(".ci-variable-row:nth-child(2) .js-ci-variable-input-key").value).to eq('BBB') + expect(find(".ci-variable-row:nth-child(2) .js-ci-variable-input-value", visible: false).value).to eq('BBB123') + end end end - end - describe 'POST /projects/pipeline_schedules/new' do - before do - visit_new_pipeline_schedule - end + context 'when user edits a variable of a pipeline schedule' do + before do + create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule| + create(:ci_pipeline_schedule_variable, key: 'AAA', value: 'AAA123', pipeline_schedule: pipeline_schedule) + end - it 'sets defaults for timezone and target branch' do - expect(page).to have_button('master') - expect(page).to have_button('Select timezone') + visit_pipelines_schedules + find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click + find('.js-ci-variable-list-section .js-secret-value-reveal-button').click + first('.js-ci-variable-input-key').set('foo') + first('.js-ci-variable-input-value').set('bar') + click_button 'Save pipeline schedule' + end + + it 'user sees the updated variable in edit window' do + find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click + page.within('.ci-variable-list') do + expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('foo') + expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-value", visible: false).value).to eq('bar') + end + end end - it 'creates a new scheduled pipeline' do - fill_in_schedule_form - save_pipeline_schedule + context 'when user removes a variable of a pipeline schedule' do + before do + create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule| + create(:ci_pipeline_schedule_variable, key: 'AAA', value: 'AAA123', pipeline_schedule: pipeline_schedule) + end - expect(page).to have_content('my fancy description') + visit_pipelines_schedules + find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click + find('.ci-variable-list .ci-variable-row-remove-button').click + click_button 'Save pipeline schedule' + end + + it 'user does not see the removed variable in edit window' do + find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click + page.within('.ci-variable-list') do + expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('') + expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-value", visible: false).value).to eq('') + end + end end - it 'prevents an invalid form from being submitted' do - save_pipeline_schedule + context 'when active is true and next_run_at is NULL' do + before do + create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule| + pipeline_schedule.update_attribute(:next_run_at, nil) # Consequently next_run_at will be nil + end + end + + it 'user edit and recover the problematic pipeline schedule' do + visit_pipelines_schedules + find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click + fill_in 'schedule_cron', with: '* 1 2 3 4' + click_button 'Save pipeline schedule' - expect(page).to have_content('This field is required') + page.within('.pipeline-schedule-table-row:nth-child(1)') do + expect(page).to have_css("[data-testid='next-run-cell'] time") + end + end end end - context 'when user creates a new pipeline schedule with variables' do + context 'logged in as non-member' do before do - visit_pipelines_schedules - click_link 'New schedule' - fill_in_schedule_form - all('[name="schedule[variables_attributes][][key]"]')[0].set('AAA') - all('[name="schedule[variables_attributes][][secret_value]"]')[0].set('AAA123') - all('[name="schedule[variables_attributes][][key]"]')[1].set('BBB') - all('[name="schedule[variables_attributes][][secret_value]"]')[1].set('BBB123') - save_pipeline_schedule + gitlab_sign_in(user) end - it 'user sees the new variable in edit window', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/397040' do - find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click - page.within('.ci-variable-list') do - expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('AAA') - expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-value", visible: false).value).to eq('AAA123') - expect(find(".ci-variable-row:nth-child(2) .js-ci-variable-input-key").value).to eq('BBB') - expect(find(".ci-variable-row:nth-child(2) .js-ci-variable-input-value", visible: false).value).to eq('BBB123') + describe 'GET /projects/pipeline_schedules' do + before do + visit_pipelines_schedules + end + + describe 'The view' do + it 'does not show create schedule button' do + expect(page).not_to have_link('New schedule') + end end end end - context 'when user edits a variable of a pipeline schedule' do - before do - create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule| - create(:ci_pipeline_schedule_variable, key: 'AAA', value: 'AAA123', pipeline_schedule: pipeline_schedule) + context 'not logged in' do + describe 'GET /projects/pipeline_schedules' do + before do + visit_pipelines_schedules end - visit_pipelines_schedules - find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click - - find('.js-ci-variable-list-section .js-secret-value-reveal-button').click - first('.js-ci-variable-input-key').set('foo') - first('.js-ci-variable-input-value').set('bar') - click_button 'Save pipeline schedule' - end - - it 'user sees the updated variable in edit window' do - find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click - page.within('.ci-variable-list') do - expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('foo') - expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-value", visible: false).value).to eq('bar') + describe 'The view' do + it 'does not show create schedule button' do + expect(page).not_to have_link('New schedule') + end end end end + end - context 'when user removes a variable of a pipeline schedule' do + context 'with pipeline_schedules_vue feature flag turned on' do + context 'logged in as a project maintainer' do before do - create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule| - create(:ci_pipeline_schedule_variable, key: 'AAA', value: 'AAA123', pipeline_schedule: pipeline_schedule) - end - - visit_pipelines_schedules - find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click - find('.ci-variable-list .ci-variable-row-remove-button').click - click_button 'Save pipeline schedule' + project.add_maintainer(maintainer) + pipeline_schedule.update!(owner: user) + gitlab_sign_in(maintainer) end - it 'user does not see the removed variable in edit window' do - find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click - page.within('.ci-variable-list') do - expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('') - expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-value", visible: false).value).to eq('') - end - end - end + describe 'GET /projects/pipeline_schedules' do + before do + visit_pipelines_schedules - context 'when active is true and next_run_at is NULL' do - before do - create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule| - pipeline_schedule.update_attribute(:next_run_at, nil) # Consequently next_run_at will be nil + wait_for_requests end - end - it 'user edit and recover the problematic pipeline schedule' do - visit_pipelines_schedules - find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click - fill_in 'schedule_cron', with: '* 1 2 3 4' - click_button 'Save pipeline schedule' + describe 'The view' do + it 'displays the required information description' do + page.within('[data-testid="pipeline-schedule-table-row"]') do + expect(page).to have_content('pipeline schedule') + expect(find("[data-testid='next-run-cell'] time")['title']) + .to include(pipeline_schedule.real_next_run.strftime('%b %-d, %Y')) + expect(page).to have_link('master') + expect(find("[data-testid='last-pipeline-status'] a")['href']).to include(pipeline.id.to_s) + end + end + + it 'changes ownership of the pipeline' do + click_button 'Take ownership' + + page.within('#pipeline-take-ownership-modal') do + click_button 'Take ownership' + + wait_for_requests + end + + page.within('[data-testid="pipeline-schedule-table-row"]') do + expect(page).not_to have_content('No owner') + expect(page).to have_link('Sidney Jones') + end + end - page.within('.pipeline-schedule-table-row:nth-child(1)') do - expect(page).to have_css("[data-testid='next-run-cell'] time") + it 'runs the pipeline' do + click_button 'Run pipeline schedule' + + wait_for_requests + + expect(page).to have_content("Successfully scheduled a pipeline to run. Go to the Pipelines page for details.") + end + + it 'deletes the pipeline' do + click_button 'Delete pipeline schedule' + + accept_gl_confirm(button_text: 'Delete pipeline schedule') + + expect(page).not_to have_css('[data-testid="pipeline-schedule-table-row"]') + end end end end - end - - context 'logged in as non-member' do - before do - gitlab_sign_in(user) - end - describe 'GET /projects/pipeline_schedules' do + context 'logged in as non-member' do before do - visit_pipelines_schedules + gitlab_sign_in(user) end - describe 'The view' do - it 'does not show create schedule button' do - expect(page).not_to have_link('New schedule') + describe 'GET /projects/pipeline_schedules' do + before do + visit_pipelines_schedules + + wait_for_requests + end + + describe 'The view' do + it 'does not show create schedule button' do + expect(page).not_to have_link('New schedule') + end end end end - end - context 'not logged in' do - describe 'GET /projects/pipeline_schedules' do - before do - visit_pipelines_schedules - end + context 'not logged in' do + describe 'GET /projects/pipeline_schedules' do + before do + visit_pipelines_schedules + + wait_for_requests + end - describe 'The view' do - it 'does not show create schedule button' do - expect(page).not_to have_link('New schedule') + describe 'The view' do + it 'does not show create schedule button' do + expect(page).not_to have_link('New schedule') + end end end end diff --git a/spec/fixtures/api/schemas/internal/pages/lookup_path.json b/spec/fixtures/api/schemas/internal/pages/lookup_path.json index 8ca71870911..c6aa2122058 100644 --- a/spec/fixtures/api/schemas/internal/pages/lookup_path.json +++ b/spec/fixtures/api/schemas/internal/pages/lookup_path.json @@ -24,7 +24,7 @@ "additionalProperties": false }, "prefix": { "type": "string" }, - "unique_domain": { "type": ["string", "null"] } + "unique_url": { "type": ["string", "null"] } }, "additionalProperties": false } diff --git a/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js b/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js index 694c16a85c4..66f1ca2b32a 100644 --- a/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js +++ b/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js @@ -19,7 +19,6 @@ describe('ManageTwoFactorForm', () => { wrapper = mountExtended(ManageTwoFactorForm, { provide: { ...defaultProvide, - webauthnEnabled: options?.webauthnEnabled ?? false, isCurrentPasswordRequired: options?.currentPasswordRequired ?? true, }, stubs: { @@ -91,17 +90,7 @@ describe('ManageTwoFactorForm', () => { describe('when clicked', () => { itShowsValidationMessageIfCurrentPasswordFieldIsEmpty(findDisableButton); - itShowsConfirmationModal(i18n.confirm); - - describe('when webauthnEnabled', () => { - beforeEach(() => { - createComponent({ - webauthnEnabled: true, - }); - }); - - itShowsConfirmationModal(i18n.confirmWebAuthn); - }); + itShowsConfirmationModal(i18n.confirmWebAuthn); it('modifies the form action and method when submitted through the button', async () => { const form = findForm(); diff --git a/spec/frontend/design_management/components/design_overlay_spec.js b/spec/frontend/design_management/components/design_overlay_spec.js index 2807fe7727f..3eb47fdb97e 100644 --- a/spec/frontend/design_management/components/design_overlay_spec.js +++ b/spec/frontend/design_management/components/design_overlay_spec.js @@ -1,6 +1,6 @@ -import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import DesignOverlay from '~/design_management/components/design_overlay.vue'; @@ -16,22 +16,20 @@ describe('Design overlay component', () => { const mockDimensions = { width: 100, height: 100 }; - const findOverlay = () => wrapper.find('[data-testid="design-overlay"]'); - const findAllNotes = () => wrapper.findAll('[data-testid="note-pin"]'); - const findCommentBadge = () => wrapper.find('[data-testid="comment-badge"]'); + const findOverlay = () => wrapper.findByTestId('design-overlay'); + const findAllNotes = () => wrapper.findAllByTestId('note-pin'); + const findCommentBadge = () => wrapper.findByTestId('comment-badge'); const findBadgeAtIndex = (noteIndex) => findAllNotes().at(noteIndex); const findFirstBadge = () => findBadgeAtIndex(0); const findSecondBadge = () => findBadgeAtIndex(1); - const clickAndDragBadge = async (elem, fromPoint, toPoint) => { + const clickAndDragBadge = (elem, fromPoint, toPoint) => { elem.vm.$emit( 'mousedown', new MouseEvent('click', { clientX: fromPoint.x, clientY: fromPoint.y }), ); findOverlay().trigger('mousemove', { clientX: toPoint.x, clientY: toPoint.y }); - await nextTick(); elem.vm.$emit('mouseup', new MouseEvent('click', { clientX: toPoint.x, clientY: toPoint.y })); - await nextTick(); }; function createComponent(props = {}, data = {}) { @@ -47,7 +45,7 @@ describe('Design overlay component', () => { }, }); - wrapper = shallowMount(DesignOverlay, { + wrapper = shallowMountExtended(DesignOverlay, { apolloProvider, propsData: { dimensions: mockDimensions, @@ -80,7 +78,7 @@ describe('Design overlay component', () => { expect(wrapper.attributes().style).toBe('width: 100px; height: 100px; top: 0px; left: 0px;'); }); - it('should emit `openCommentForm` when clicking on overlay', async () => { + it('should emit `openCommentForm` when clicking on overlay', () => { createComponent(); const newCoordinates = { x: 10, @@ -90,7 +88,7 @@ describe('Design overlay component', () => { wrapper .find('[data-qa-selector="design_image_button"]') .trigger('mouseup', { offsetX: newCoordinates.x, offsetY: newCoordinates.y }); - await nextTick(); + expect(wrapper.emitted('openCommentForm')).toEqual([ [{ x: newCoordinates.x, y: newCoordinates.y }], ]); @@ -175,25 +173,15 @@ describe('Design overlay component', () => { }); }); - it('should recalculate badges positions on window resize', async () => { + it('should calculate badges positions based on dimensions', () => { createComponent({ notes, dimensions: { - width: 400, - height: 400, - }, - }); - - expect(findFirstBadge().props('position')).toEqual({ left: '40px', top: '60px' }); - - wrapper.setProps({ - dimensions: { width: 200, height: 200, }, }); - await nextTick(); expect(findFirstBadge().props('position')).toEqual({ left: '20px', top: '30px' }); }); @@ -216,7 +204,6 @@ describe('Design overlay component', () => { new MouseEvent('click', { clientX: position.x, clientY: position.y }), ); - await nextTick(); findFirstBadge().vm.$emit( 'mouseup', new MouseEvent('click', { clientX: position.x, clientY: position.y }), @@ -290,7 +277,7 @@ describe('Design overlay component', () => { }); describe('when moving the comment badge', () => { - it('should update badge style when note-moving action ends', async () => { + it('should update badge style when note-moving action ends', () => { const { position } = notes[0]; createComponent({ currentCommentForm: { @@ -298,19 +285,15 @@ describe('Design overlay component', () => { }, }); - const commentBadge = findCommentBadge(); + expect(findCommentBadge().props('position')).toEqual({ left: '10px', top: '15px' }); + const toPoint = { x: 20, y: 20 }; - await clickAndDragBadge(commentBadge, { x: position.x, y: position.y }, toPoint); - commentBadge.vm.$emit('mouseup', new MouseEvent('click')); - // simulates the currentCommentForm being updated in index.vue component, and - // propagated back down to this prop - wrapper.setProps({ + createComponent({ currentCommentForm: { height: position.height, width: position.width, ...toPoint }, }); - await nextTick(); - expect(commentBadge.props('position')).toEqual({ left: '20px', top: '20px' }); + expect(findCommentBadge().props('position')).toEqual({ left: '20px', top: '20px' }); }); it('should emit `openCommentForm` event when mouseleave fired on overlay element', async () => { @@ -330,8 +313,7 @@ describe('Design overlay component', () => { newCoordinates, ); - wrapper.trigger('mouseleave'); - await nextTick(); + findOverlay().vm.$emit('mouseleave'); expect(wrapper.emitted('openCommentForm')).toEqual([[newCoordinates]]); }); diff --git a/spec/frontend/diffs/components/diff_line_note_form_spec.js b/spec/frontend/diffs/components/diff_line_note_form_spec.js index bd0e3455872..eb895bd9057 100644 --- a/spec/frontend/diffs/components/diff_line_note_form_spec.js +++ b/spec/frontend/diffs/components/diff_line_note_form_spec.js @@ -1,7 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import Vuex from 'vuex'; -import Autosave from '~/autosave'; import DiffLineNoteForm from '~/diffs/components/diff_line_note_form.vue'; import { createModules } from '~/mr_notes/stores'; import NoteForm from '~/notes/components/note_form.vue'; @@ -11,7 +10,6 @@ import { noteableDataMock } from 'jest/notes/mock_data'; import { getDiffFileMock } from '../mock_data/diff_file'; jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); -jest.mock('~/autosave'); describe('DiffLineNoteForm', () => { let wrapper; @@ -77,7 +75,6 @@ describe('DiffLineNoteForm', () => { const findCommentForm = () => wrapper.findComponent(MultilineCommentForm); beforeEach(() => { - Autosave.mockClear(); createComponent(); }); @@ -100,19 +97,6 @@ describe('DiffLineNoteForm', () => { }); }); - it('should init autosave', () => { - // we're using shallow mount here so there's no element to pass to Autosave - expect(Autosave).toHaveBeenCalledWith(undefined, [ - 'Note', - 'Issue', - 98, - undefined, - 'DiffNote', - undefined, - '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_2', - ]); - }); - describe('when cancelling form', () => { afterEach(() => { confirmAction.mockReset(); @@ -146,7 +130,6 @@ describe('DiffLineNoteForm', () => { await nextTick(); expect(getSelectedLine().hasForm).toBe(false); - expect(Autosave.mock.instances[0].reset).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/lib/utils/error_message_spec.js b/spec/frontend/lib/utils/error_message_spec.js index 17b5168c32f..54c630b8ba0 100644 --- a/spec/frontend/lib/utils/error_message_spec.js +++ b/spec/frontend/lib/utils/error_message_spec.js @@ -1,65 +1,43 @@ import { parseErrorMessage, USER_FACING_ERROR_MESSAGE_PREFIX } from '~/lib/utils/error_message'; -const defaultErrorMessage = 'Something caused this error'; -const userFacingErrorMessage = 'User facing error message'; -const nonUserFacingErrorMessage = 'NonUser facing error message'; -const genericErrorMessage = 'Some error message'; - -describe('error message', () => { - describe('when given an errormessage object', () => { - const errorMessageObject = { - options: { - cause: defaultErrorMessage, - }, - filename: 'error.js', - linenumber: 7, - }; - - it('returns the correct values for userfacing errors', () => { - const userFacingObject = errorMessageObject; - userFacingObject.message = `${USER_FACING_ERROR_MESSAGE_PREFIX} ${userFacingErrorMessage}`; - - expect(parseErrorMessage(userFacingObject)).toEqual({ - message: userFacingErrorMessage, - userFacing: true, - }); - }); - - it('returns the correct values for non userfacing errors', () => { - const nonUserFacingObject = errorMessageObject; - nonUserFacingObject.message = nonUserFacingErrorMessage; - - expect(parseErrorMessage(nonUserFacingObject)).toEqual({ - message: nonUserFacingErrorMessage, - userFacing: false, - }); - }); - }); - - describe('when given an errormessage string', () => { - it('returns the correct values for userfacing errors', () => { - expect( - parseErrorMessage(`${USER_FACING_ERROR_MESSAGE_PREFIX} ${genericErrorMessage}`), - ).toEqual({ - message: genericErrorMessage, - userFacing: true, - }); - }); - - it('returns the correct values for non userfacing errors', () => { - expect(parseErrorMessage(genericErrorMessage)).toEqual({ - message: genericErrorMessage, - userFacing: false, - }); - }); - }); - - describe('when given nothing', () => { - it('returns an empty error message', () => { - expect(parseErrorMessage()).toEqual({ - message: '', - userFacing: false, - }); - }); - }); +const defaultErrorMessage = 'Default error message'; +const errorMessage = 'Returned error message'; + +const generateErrorWithMessage = (message) => { + return { + message, + }; +}; + +describe('parseErrorMessage', () => { + it.each` + error | expectedResult + ${`${USER_FACING_ERROR_MESSAGE_PREFIX} ${errorMessage}`} | ${errorMessage} + ${`${errorMessage} ${USER_FACING_ERROR_MESSAGE_PREFIX}`} | ${defaultErrorMessage} + ${errorMessage} | ${defaultErrorMessage} + ${undefined} | ${defaultErrorMessage} + ${''} | ${defaultErrorMessage} + `( + 'properly parses "$error" error object and returns "$expectedResult"', + ({ error, expectedResult }) => { + const errorObject = generateErrorWithMessage(error); + expect(parseErrorMessage(errorObject, defaultErrorMessage)).toEqual(expectedResult); + }, + ); + + it.each` + error | defaultMessage | expectedResult + ${undefined} | ${defaultErrorMessage} | ${defaultErrorMessage} + ${''} | ${defaultErrorMessage} | ${defaultErrorMessage} + ${{}} | ${defaultErrorMessage} | ${defaultErrorMessage} + ${generateErrorWithMessage(errorMessage)} | ${undefined} | ${''} + ${generateErrorWithMessage(`${USER_FACING_ERROR_MESSAGE_PREFIX} ${errorMessage}`)} | ${undefined} | ${errorMessage} + ${generateErrorWithMessage(errorMessage)} | ${''} | ${''} + ${generateErrorWithMessage(`${USER_FACING_ERROR_MESSAGE_PREFIX} ${errorMessage}`)} | ${''} | ${errorMessage} + `( + 'properly handles the edge case of error="$error" and defaultMessage="$defaultMessage"', + ({ error, defaultMessage, expectedResult }) => { + expect(parseErrorMessage(error, defaultMessage)).toEqual(expectedResult); + }, + ); }); diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index 062cd098640..891b5c751fb 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -5,6 +5,7 @@ import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import batchComments from '~/batch_comments/stores/modules/batch_comments'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { createAlert } from '~/alert'; @@ -27,6 +28,8 @@ jest.mock('~/alert'); Vue.use(Vuex); describe('issue_comment_form component', () => { + useLocalStorageSpy(); + let store; let wrapper; let axiosMock; diff --git a/spec/frontend/notes/components/note_body_spec.js b/spec/frontend/notes/components/note_body_spec.js index b4f185004bb..c4f8e50b969 100644 --- a/spec/frontend/notes/components/note_body_spec.js +++ b/spec/frontend/notes/components/note_body_spec.js @@ -7,10 +7,7 @@ import NoteAwardsList from '~/notes/components/note_awards_list.vue'; import NoteForm from '~/notes/components/note_form.vue'; import createStore from '~/notes/stores'; import notes from '~/notes/stores/modules/index'; -import Autosave from '~/autosave'; - import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; - import { noteableDataMock, notesDataMock, note } from '../mock_data'; jest.mock('~/autosave'); @@ -82,11 +79,6 @@ describe('issue_note_body component', () => { expect(wrapper.findComponent(NoteForm).props('saveButtonTitle')).toBe(buttonText); }); - it('adds autosave', () => { - // passing undefined instead of an element because of shallowMount - expect(Autosave).toHaveBeenCalledWith(undefined, ['Note', note.noteable_type, note.id]); - }); - describe('isInternalNote', () => { beforeEach(() => { wrapper.setProps({ isInternalNote: true }); diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js index 59362e18098..12c3b154fc7 100644 --- a/spec/frontend/notes/components/note_form_spec.js +++ b/spec/frontend/notes/components/note_form_spec.js @@ -2,7 +2,6 @@ import { GlLink } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import batchComments from '~/batch_comments/stores/modules/batch_comments'; -import { getDraft, updateDraft } from '~/lib/utils/autosave'; import NoteForm from '~/notes/components/note_form.vue'; import createStore from '~/notes/stores'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; @@ -12,30 +11,25 @@ import { noteableDataMock, notesDataMock, discussionMock, note } from '../mock_d jest.mock('~/lib/utils/autosave'); describe('issue_note_form component', () => { - const dummyAutosaveKey = 'some-autosave-key'; - const dummyDraft = 'dummy draft content'; - let store; let wrapper; let props; + let features; const createComponentWrapper = () => { return mount(NoteForm, { store, propsData: props, + provide: { + glFeatures: features || {}, + }, }); }; const findCancelButton = () => wrapper.find('[data-testid="cancel"]'); beforeEach(() => { - getDraft.mockImplementation((key) => { - if (key === dummyAutosaveKey) { - return dummyDraft; - } - - return null; - }); + features = {}; store = createStore(); store.dispatch('setNoteableData', noteableDataMock); @@ -68,6 +62,20 @@ describe('issue_note_form component', () => { }); }); + it('hides content editor switcher if feature flag content_editor_on_issues is off', () => { + features = { contentEditorOnIssues: false }; + wrapper = createComponentWrapper(); + + expect(wrapper.text()).not.toContain('Rich text'); + }); + + it('shows content editor switcher if feature flag content_editor_on_issues is on', () => { + features = { contentEditorOnIssues: true }; + wrapper = createComponentWrapper(); + + expect(wrapper.text()).toContain('Rich text'); + }); + describe('conflicts editing', () => { beforeEach(() => { wrapper = createComponentWrapper(); @@ -117,13 +125,15 @@ describe('issue_note_form component', () => { ${true} | ${'Write an internal note or drag your files here…'} `( 'should set correct textarea placeholder text when discussion confidentiality is $internal', - ({ internal, placeholder }) => { + async ({ internal, placeholder }) => { props.note = { ...note, internal, }; wrapper = createComponentWrapper(); + await nextTick(); + expect(wrapper.find('textarea').attributes('placeholder')).toBe(placeholder); }, ); @@ -204,7 +214,7 @@ describe('issue_note_form component', () => { }); await nextTick(); - const textareaEl = wrapper.vm.$refs.textarea; + const textareaEl = wrapper.vm.$refs.markdownEditor.$el.querySelector('textarea'); const cancelButton = findCancelButton(); textareaEl.classList.add(AT_WHO_ACTIVE_CLASS); cancelButton.vm.$emit('click'); @@ -229,78 +239,6 @@ describe('issue_note_form component', () => { }); }); - describe('with autosaveKey', () => { - describe('with draft', () => { - beforeEach(() => { - Object.assign(props, { - noteBody: '', - autosaveKey: dummyAutosaveKey, - }); - wrapper = createComponentWrapper(); - - return nextTick(); - }); - - it('displays the draft in textarea', () => { - const textarea = wrapper.find('textarea'); - - expect(textarea.element.value).toBe(dummyDraft); - }); - }); - - describe('without draft', () => { - beforeEach(() => { - Object.assign(props, { - noteBody: '', - autosaveKey: 'some key without draft', - }); - wrapper = createComponentWrapper(); - - return nextTick(); - }); - - it('leaves the textarea empty', () => { - const textarea = wrapper.find('textarea'); - - expect(textarea.element.value).toBe(''); - }); - }); - - it('updates the draft if textarea content changes', () => { - Object.assign(props, { - noteBody: '', - autosaveKey: dummyAutosaveKey, - }); - wrapper = createComponentWrapper(); - const textarea = wrapper.find('textarea'); - const dummyContent = 'some new content'; - - textarea.setValue(dummyContent); - - expect(updateDraft).toHaveBeenCalledWith(dummyAutosaveKey, dummyContent); - }); - - it('does not save draft when ctrl+enter is pressed', () => { - const options = { - noteBody: '', - autosaveKey: dummyAutosaveKey, - }; - - props = { ...props, ...options }; - wrapper = createComponentWrapper(); - - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ isSubmittingWithKeydown: true }); - - const textarea = wrapper.find('textarea'); - textarea.setValue('some content'); - textarea.trigger('keydown.enter', { metaKey: true }); - - expect(updateDraft).not.toHaveBeenCalled(); - }); - }); - describe('with batch comments', () => { beforeEach(() => { store.registerModule('batchComments', batchComments()); diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js index 0ca350f9ed7..ae5316eb12f 100644 --- a/spec/frontend/security_configuration/components/app_spec.js +++ b/spec/frontend/security_configuration/components/app_spec.js @@ -26,8 +26,6 @@ import { REPORT_TYPE_LICENSE_COMPLIANCE, REPORT_TYPE_SAST, } from '~/vue_shared/security_reports/constants'; -import { USER_FACING_ERROR_MESSAGE_PREFIX } from '~/lib/utils/error_message'; -import { manageViaMRErrorMessage } from '../constants'; const upgradePath = '/upgrade'; const autoDevopsHelpPagePath = '/autoDevopsHelpPagePath'; @@ -202,21 +200,20 @@ describe('App component', () => { }); }); - describe('when user facing error occurs', () => { + describe('when error occurs', () => { + const errorMessage = 'There was a manage via MR error'; + it('should show Alert with error Message', async () => { expect(findManageViaMRErrorAlert().exists()).toBe(false); - // Prefixed with USER_FACING_ERROR_MESSAGE_PREFIX as used in lib/gitlab/utils/error_message.rb to indicate a user facing error - findFeatureCards() - .at(1) - .vm.$emit('error', `${USER_FACING_ERROR_MESSAGE_PREFIX} ${manageViaMRErrorMessage}`); + findFeatureCards().at(1).vm.$emit('error', errorMessage); await nextTick(); expect(findManageViaMRErrorAlert().exists()).toBe(true); - expect(findManageViaMRErrorAlert().text()).toEqual(manageViaMRErrorMessage); + expect(findManageViaMRErrorAlert().text()).toBe(errorMessage); }); it('should hide Alert when it is dismissed', async () => { - findFeatureCards().at(1).vm.$emit('error', manageViaMRErrorMessage); + findFeatureCards().at(1).vm.$emit('error', errorMessage); await nextTick(); expect(findManageViaMRErrorAlert().exists()).toBe(true); @@ -226,17 +223,6 @@ describe('App component', () => { expect(findManageViaMRErrorAlert().exists()).toBe(false); }); }); - - describe('when non-user facing error occurs', () => { - it('should show Alert with generic error Message', async () => { - expect(findManageViaMRErrorAlert().exists()).toBe(false); - findFeatureCards().at(1).vm.$emit('error', manageViaMRErrorMessage); - - await nextTick(); - expect(findManageViaMRErrorAlert().exists()).toBe(true); - expect(findManageViaMRErrorAlert().text()).toEqual(i18n.genericErrorText); - }); - }); }); describe('Auto DevOps hint alert', () => { diff --git a/spec/frontend/super_sidebar/components/context_switcher_spec.js b/spec/frontend/super_sidebar/components/context_switcher_spec.js index 538e87cf843..92df8129799 100644 --- a/spec/frontend/super_sidebar/components/context_switcher_spec.js +++ b/spec/frontend/super_sidebar/components/context_switcher_spec.js @@ -5,6 +5,7 @@ import * as Sentry from '@sentry/browser'; import { s__ } from '~/locale'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ContextSwitcher from '~/super_sidebar/components/context_switcher.vue'; +import NavItem from '~/super_sidebar/components/nav_item.vue'; import ProjectsList from '~/super_sidebar/components/projects_list.vue'; import GroupsList from '~/super_sidebar/components/groups_list.vue'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -23,6 +24,7 @@ jest.mock('~/super_sidebar/utils', () => ({ trackContextAccess: jest.fn(), })); +const persistentLinks = [{ title: 'Explore', link: '/explore', icon: 'compass' }]; const username = 'root'; const projectsPath = 'projectsPath'; const groupsPath = 'groupsPath'; @@ -33,6 +35,7 @@ describe('ContextSwitcher component', () => { let wrapper; let mockApollo; + const findNavItems = () => wrapper.findAllComponents(NavItem); const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); const findProjectsList = () => wrapper.findComponent(ProjectsList); const findGroupsList = () => wrapper.findComponent(GroupsList); @@ -60,6 +63,7 @@ describe('ContextSwitcher component', () => { wrapper = shallowMountExtended(ContextSwitcher, { apolloProvider: mockApollo, propsData: { + persistentLinks, username, projectsPath, groupsPath, @@ -84,6 +88,12 @@ describe('ContextSwitcher component', () => { createWrapper(); }); + it('renders the persistent links', () => { + const navItems = findNavItems(); + expect(navItems.length).toBe(persistentLinks.length); + expect(navItems.at(0).props('item')).toBe(persistentLinks[0]); + }); + it('passes the placeholder to the search box', () => { expect(findSearchBox().props('placeholder')).toBe( s__('Navigation|Search for projects or groups'), @@ -138,6 +148,10 @@ describe('ContextSwitcher component', () => { return triggerSearchQuery(); }); + it('hides persistent links', () => { + expect(findNavItems().length).toBe(0); + }); + it('triggers the search query on search', () => { expect(searchUserProjectsAndGroupsHandlerSuccess).toHaveBeenCalled(); }); diff --git a/spec/frontend/super_sidebar/components/search_results_spec.js b/spec/frontend/super_sidebar/components/search_results_spec.js index dd48935c138..daec5c2a9b4 100644 --- a/spec/frontend/super_sidebar/components/search_results_spec.js +++ b/spec/frontend/super_sidebar/components/search_results_spec.js @@ -1,7 +1,9 @@ +import { GlCollapse } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { s__ } from '~/locale'; import SearchResults from '~/super_sidebar/components/search_results.vue'; import ItemsList from '~/super_sidebar/components/items_list.vue'; +import { stubComponent } from 'helpers/stub_component'; const title = s__('Navigation|PROJECTS'); const noResultsText = s__('Navigation|No project matches found'); @@ -9,7 +11,8 @@ const noResultsText = s__('Navigation|No project matches found'); describe('SearchResults component', () => { let wrapper; - const findListTitle = () => wrapper.findByTestId('list-title'); + const findSearchResultsToggle = () => wrapper.findByTestId('search-results-toggle'); + const findCollapsibleSection = () => wrapper.findComponent(GlCollapse); const findItemsList = () => wrapper.findComponent(ItemsList); const findEmptyText = () => wrapper.findByTestId('empty-text'); @@ -20,6 +23,11 @@ describe('SearchResults component', () => { noResultsText, ...props, }, + stubs: { + GlCollapse: stubComponent(GlCollapse, { + props: ['visible'], + }), + }, }); }; @@ -29,7 +37,11 @@ describe('SearchResults component', () => { }); it("renders the list's title", () => { - expect(findListTitle().text()).toBe(title); + expect(findSearchResultsToggle().text()).toBe(title); + }); + + it('is expanded', () => { + expect(findCollapsibleSection().props('visible')).toBe(true); }); it('renders the empty text', () => { diff --git a/spec/frontend/super_sidebar/mock_data.js b/spec/frontend/super_sidebar/mock_data.js index b540f85d9fe..8c70693465f 100644 --- a/spec/frontend/super_sidebar/mock_data.js +++ b/spec/frontend/super_sidebar/mock_data.js @@ -86,6 +86,7 @@ export const sidebarData = { gitlab_version_check: { severity: 'success' }, gitlab_com_and_canary: false, canary_toggle_com_url: 'https://next.gitlab.com', + context_switcher_links: [], }; export const userMenuMockStatus = { diff --git a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js index 681ff6c8dd3..7bda37bcaa8 100644 --- a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js +++ b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js @@ -275,7 +275,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => { await findTextarea().setValue(newValue); - expect(wrapper.emitted('input')).toEqual([[newValue]]); + expect(wrapper.emitted('input')).toEqual([[value], [newValue]]); }); it('autosaves the markdown value to local storage', async () => { @@ -370,7 +370,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => { await findContentEditor().vm.$emit('change', { markdown: newValue }); - expect(wrapper.emitted('input')).toEqual([[newValue]]); + expect(wrapper.emitted('input')).toEqual([[value], [newValue]]); }); it('autosaves the content editor value to local storage', async () => { diff --git a/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js b/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js index 6345393951c..646b37d334b 100644 --- a/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js +++ b/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js @@ -8,7 +8,9 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { humanize } from '~/lib/utils/text_utility'; import { redirectTo } from '~/lib/utils/url_utility'; -import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue'; +import ManageViaMr, { + i18n, +} from '~/vue_shared/security_configuration/components/manage_via_mr.vue'; import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants'; import { buildConfigureSecurityFeatureMockFactory } from './apollo_mocks'; @@ -77,10 +79,11 @@ describe('ManageViaMr component', () => { buildConfigureSecurityFeatureMock({ successPath: '', }); - const errorHandler = async () => - buildConfigureSecurityFeatureMock({ - errors: ['foo'], + const errorHandler = async (message = 'foo') => { + return buildConfigureSecurityFeatureMock({ + errors: [message], }); + }; const pendingHandler = () => new Promise(() => {}); describe('when feature is configured', () => { @@ -147,9 +150,11 @@ describe('ManageViaMr component', () => { }); describe.each` - handler | message - ${noSuccessPathHandler} | ${`${featureName} merge request creation mutation failed`} - ${errorHandler} | ${'foo'} + handler | message + ${noSuccessPathHandler} | ${`${featureName} merge request creation mutation failed`} + ${errorHandler.bind(null, 'UF: message')} | ${'message'} + ${errorHandler.bind(null, 'message')} | ${i18n.genericErrorText} + ${errorHandler} | ${i18n.genericErrorText} `('given an error response', ({ handler, message }) => { beforeEach(() => { const apolloProvider = createMockApolloProvider(mutation, handler); diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb index ba703914049..2cea577a852 100644 --- a/spec/helpers/search_helper_spec.rb +++ b/spec/helpers/search_helper_spec.rb @@ -306,6 +306,46 @@ RSpec.describe SearchHelper, feature_category: :global_search do end end + describe 'projects_autocomplete' do + let_it_be(:user) { create(:user, name: "madelein") } + let_it_be(:project_1) { create(:project, name: 'test 1') } + let_it_be(:project_2) { create(:project, name: 'test 2') } + let(:search_term) { 'test' } + + before do + allow(self).to receive(:current_user).and_return(user) + end + + context 'when the user does not have access to projects' do + it 'does not return any results' do + expect(projects_autocomplete(search_term)).to eq([]) + end + end + + context 'when the user has access to one project' do + before do + project_2.add_developer(user) + end + + it 'returns the project' do + expect(projects_autocomplete(search_term).pluck(:id)).to eq([project_2.id]) + end + + context 'when a project namespace matches the search term but the project does not' do + let_it_be(:group) { create(:group, name: 'test group') } + let_it_be(:project_3) { create(:project, name: 'nothing', namespace: group) } + + before do + group.add_owner(user) + end + + it 'returns all projects matching the term' do + expect(projects_autocomplete(search_term).pluck(:id)).to match_array([project_2.id, project_3.id]) + end + end + end + end + describe 'search_entries_info' do using RSpec::Parameterized::TableSyntax diff --git a/spec/helpers/sidebars_helper_spec.rb b/spec/helpers/sidebars_helper_spec.rb index dbb6f9bd9f3..ea48246fabe 100644 --- a/spec/helpers/sidebars_helper_spec.rb +++ b/spec/helpers/sidebars_helper_spec.rb @@ -246,6 +246,38 @@ RSpec.describe SidebarsHelper, feature_category: :navigation do end end end + + describe 'context switcher persistent links' do + let_it_be(:public_link) do + [ + { title: s_('Navigation|Your work'), link: '/', icon: 'work' }, + { title: s_('Navigation|Explore'), link: '/explore', icon: 'compass' } + ] + end + + subject do + helper.super_sidebar_context(user, group: nil, project: nil, panel: panel) + end + + context 'when user is not an admin' do + it 'returns only the public links' do + expect(subject[:context_switcher_links]).to eq(public_link) + end + end + + context 'when user is an admin' do + before do + allow(user).to receive(:can_admin_all_resources?).and_return(true) + end + + it 'returns public links and admin area link' do + expect(subject[:context_switcher_links]).to eq([ + *public_link, + { title: s_('Navigation|Admin'), link: '/admin', icon: 'admin' } + ]) + end + end + end end describe '#super_sidebar_nav_panel' do diff --git a/spec/migrations/insert_daily_invites_trial_plan_limits_spec.rb b/spec/migrations/insert_daily_invites_trial_plan_limits_spec.rb new file mode 100644 index 00000000000..ea1476b94a9 --- /dev/null +++ b/spec/migrations/insert_daily_invites_trial_plan_limits_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe InsertDailyInvitesTrialPlanLimits, feature_category: :subgroups do + let(:plans) { table(:plans) } + let(:plan_limits) { table(:plan_limits) } + let!(:premium_trial_plan) { plans.create!(name: 'premium_trial') } + let!(:ultimate_trial_plan) { plans.create!(name: 'ultimate_trial') } + + context 'when on gitlab.com' do + before do + allow(Gitlab).to receive(:com?).and_return(true) + end + + it 'correctly migrates up and down' do + reversible_migration do |migration| + migration.before -> { + trial_plan_ids = [premium_trial_plan.id, ultimate_trial_plan.id] + expect(plan_limits.where(plan_id: trial_plan_ids).where.not(daily_invites: 0)).to be_empty + } + + migration.after -> { + expect(plan_limits.pluck(:plan_id, :daily_invites)) + .to contain_exactly([premium_trial_plan.id, 50], [ultimate_trial_plan.id, 50]) + } + end + end + end + + context 'when on self-managed' do + before do + allow(Gitlab).to receive(:com?).and_return(false) + end + + it 'correctly migrates up and down' do + reversible_migration do |migration| + trial_plan_ids = [premium_trial_plan.id, ultimate_trial_plan.id] + + migration.before -> { + expect(plan_limits.where(plan_id: trial_plan_ids).where.not(daily_invites: 0)).to be_empty + } + + migration.after -> { + expect(plan_limits.where(plan_id: trial_plan_ids).where.not(daily_invites: 0)).to be_empty + } + end + end + end +end diff --git a/spec/models/pages/lookup_path_spec.rb b/spec/models/pages/lookup_path_spec.rb index 38ff1bb090e..26b57e2e58e 100644 --- a/spec/models/pages/lookup_path_spec.rb +++ b/spec/models/pages/lookup_path_spec.rb @@ -138,14 +138,14 @@ RSpec.describe Pages::LookupPath, feature_category: :pages do end end - describe '#unique_domain' do + describe '#unique_url' do let(:project) { build(:project) } context 'when unique domain is disabled' do it 'returns nil' do project.project_setting.pages_unique_domain_enabled = false - expect(lookup_path.unique_domain).to be_nil + expect(lookup_path.unique_url).to be_nil end end @@ -154,7 +154,7 @@ RSpec.describe Pages::LookupPath, feature_category: :pages do project.project_setting.pages_unique_domain_enabled = true project.project_setting.pages_unique_domain = 'unique-domain' - expect(lookup_path.unique_domain).to eq('unique-domain') + expect(lookup_path.unique_url).to eq('http://unique-domain.example.com') end end end diff --git a/spec/requests/api/internal/pages_spec.rb b/spec/requests/api/internal/pages_spec.rb index 20fb9100ebb..70ca1abc819 100644 --- a/spec/requests/api/internal/pages_spec.rb +++ b/spec/requests/api/internal/pages_spec.rb @@ -117,7 +117,7 @@ RSpec.describe API::Internal::Pages, feature_category: :pages do 'file_size' => deployment.size, 'file_count' => deployment.file_count }, - 'unique_domain' => nil + 'unique_url' => nil } ] ) @@ -206,7 +206,7 @@ RSpec.describe API::Internal::Pages, feature_category: :pages do 'file_size' => deployment.size, 'file_count' => deployment.file_count }, - 'unique_domain' => 'unique-domain' + 'unique_url' => 'http://unique-domain.example.com' } ] ) @@ -262,7 +262,7 @@ RSpec.describe API::Internal::Pages, feature_category: :pages do 'file_size' => deployment.size, 'file_count' => deployment.file_count }, - 'unique_domain' => nil + 'unique_url' => nil } ] ) @@ -310,7 +310,7 @@ RSpec.describe API::Internal::Pages, feature_category: :pages do 'file_size' => deployment.size, 'file_count' => deployment.file_count }, - 'unique_domain' => nil + 'unique_url' => nil } ] ) diff --git a/spec/scripts/review_apps/automated_cleanup_spec.rb b/spec/scripts/review_apps/automated_cleanup_spec.rb index 546bf55a934..4b6016760dc 100644 --- a/spec/scripts/review_apps/automated_cleanup_spec.rb +++ b/spec/scripts/review_apps/automated_cleanup_spec.rb @@ -30,10 +30,8 @@ RSpec.describe ReviewApps::AutomatedCleanup, feature_category: :tooling do allow(Tooling::Helm3Client).to receive(:new).and_return(helm_client) allow(Tooling::KubernetesClient).to receive(:new).and_return(kubernetes_client) - allow(kubernetes_client).to receive(:cleanup_by_created_at) - allow(kubernetes_client).to receive(:cleanup_by_release) - allow(kubernetes_client).to receive(:cleanup_review_app_namespaces) - allow(kubernetes_client).to receive(:delete_namespaces_by_exact_names) + allow(kubernetes_client).to receive(:cleanup_pvcs_by_created_at) + allow(kubernetes_client).to receive(:cleanup_namespaces_by_created_at) end shared_examples 'the days argument is an integer in the correct range' do @@ -86,11 +84,7 @@ RSpec.describe ReviewApps::AutomatedCleanup, feature_category: :tooling do it_behaves_like 'the days argument is an integer in the correct range' it 'performs Kubernetes cleanup by created at' do - expect(kubernetes_client).to receive(:cleanup_by_created_at).with( - resource_type: 'pvc', - created_before: two_days_ago, - wait: false - ) + expect(kubernetes_client).to receive(:cleanup_pvcs_by_created_at).with(created_before: two_days_ago) subject end @@ -99,7 +93,7 @@ RSpec.describe ReviewApps::AutomatedCleanup, feature_category: :tooling do let(:dry_run) { true } it 'does not delete anything' do - expect(kubernetes_client).not_to receive(:cleanup_by_created_at) + expect(kubernetes_client).not_to receive(:cleanup_pvcs_by_created_at) end end end @@ -112,10 +106,7 @@ RSpec.describe ReviewApps::AutomatedCleanup, feature_category: :tooling do it_behaves_like 'the days argument is an integer in the correct range' it 'performs Kubernetes cleanup for review apps namespaces' do - expect(kubernetes_client).to receive(:cleanup_review_app_namespaces).with( - created_before: two_days_ago, - wait: false - ) + expect(kubernetes_client).to receive(:cleanup_namespaces_by_created_at).with(created_before: two_days_ago) subject end @@ -124,7 +115,7 @@ RSpec.describe ReviewApps::AutomatedCleanup, feature_category: :tooling do let(:dry_run) { true } it 'does not delete anything' do - expect(kubernetes_client).not_to receive(:cleanup_review_app_namespaces) + expect(kubernetes_client).not_to receive(:cleanup_namespaces_by_created_at) end end end @@ -147,8 +138,7 @@ RSpec.describe ReviewApps::AutomatedCleanup, feature_category: :tooling do before do allow(helm_client).to receive(:delete) - allow(kubernetes_client).to receive(:cleanup_by_release) - allow(kubernetes_client).to receive(:delete_namespaces_by_exact_names) + allow(kubernetes_client).to receive(:delete_namespaces) end it 'deletes the helm release' do @@ -157,16 +147,8 @@ RSpec.describe ReviewApps::AutomatedCleanup, feature_category: :tooling do subject end - it 'empties the k8s resources in the k8s namespace for the release' do - expect(kubernetes_client).to receive(:cleanup_by_release).with(release_name: releases_names, wait: false) - - subject - end - it 'deletes the associated k8s namespace' do - expect(kubernetes_client).to receive(:delete_namespaces_by_exact_names).with( - resource_names: releases_names, wait: false - ) + expect(kubernetes_client).to receive(:delete_namespaces).with(releases_names) subject end @@ -179,14 +161,8 @@ RSpec.describe ReviewApps::AutomatedCleanup, feature_category: :tooling do subject end - it 'does not empty the k8s resources in the k8s namespace for the release' do - expect(kubernetes_client).not_to receive(:cleanup_by_release) - - subject - end - it 'does not delete the associated k8s namespace' do - expect(kubernetes_client).not_to receive(:delete_namespaces_by_exact_names) + expect(kubernetes_client).not_to receive(:delete_namespaces) subject end diff --git a/spec/tooling/lib/tooling/kubernetes_client_spec.rb b/spec/tooling/lib/tooling/kubernetes_client_spec.rb index 50d33182a42..20eb78c2f4f 100644 --- a/spec/tooling/lib/tooling/kubernetes_client_spec.rb +++ b/spec/tooling/lib/tooling/kubernetes_client_spec.rb @@ -1,286 +1,373 @@ # frozen_string_literal: true +require 'time' require_relative '../../../../tooling/lib/tooling/kubernetes_client' RSpec.describe Tooling::KubernetesClient do - let(:namespace) { 'review-apps' } - let(:release_name) { 'my-release' } - let(:pod_for_release) { "pod-my-release-abcd" } - let(:raw_resource_names_str) { "NAME\nfoo\n#{pod_for_release}\nbar" } - let(:raw_resource_names) { raw_resource_names_str.lines.map(&:strip) } - - subject { described_class.new(namespace: namespace) } + let(:instance) { described_class.new } + let(:one_day_ago) { Time.now - 3600 * 24 * 1 } + let(:two_days_ago) { Time.now - 3600 * 24 * 2 } + let(:three_days_ago) { Time.now - 3600 * 24 * 3 } + + before do + # Global mock to ensure that no kubectl commands are run by accident in a test. + allow(instance).to receive(:run_command) + end - describe 'RESOURCE_LIST' do - it 'returns the correct list of resources separated by commas' do - expect(described_class::RESOURCE_LIST).to eq('ingress,svc,pdb,hpa,deploy,statefulset,job,pod,secret,configmap,pvc,secret,clusterrole,clusterrolebinding,role,rolebinding,sa,crd') + describe '#cleanup_pvcs_by_created_at' do + let(:pvc_1_created_at) { three_days_ago } + let(:pvc_2_created_at) { three_days_ago } + let(:pvc_1_namespace) { 'review-first-review-app' } + let(:pvc_2_namespace) { 'review-second-review-app' } + let(:kubectl_pvcs_json) do + <<~JSON + { + "apiVersion": "v1", + "items": [ + { + "apiVersion": "v1", + "kind": "PersistentVolumeClaim", + "metadata": { + "creationTimestamp": "#{pvc_1_created_at.utc.iso8601}", + "name": "pvc1", + "namespace": "#{pvc_1_namespace}" + } + }, + { + "apiVersion": "v1", + "kind": "PersistentVolumeClaim", + "metadata": { + "creationTimestamp": "#{pvc_2_created_at.utc.iso8601}", + "name": "pvc2", + "namespace": "#{pvc_2_namespace}" + } + } + ] + } + JSON end - end - describe '#cleanup_by_release' do + subject { instance.cleanup_pvcs_by_created_at(created_before: two_days_ago) } + before do - allow(subject).to receive(:raw_resource_names).and_return(raw_resource_names) + allow(instance).to receive(:run_command).with( + "kubectl get pvc --all-namespaces --sort-by='{.metadata.creationTimestamp}' -o json" + ).and_return(kubectl_pvcs_json) end - shared_examples 'a kubectl command to delete resources' do - let(:wait) { true } - let(:release_names_in_command) { release_name.respond_to?(:join) ? %(-l 'release in (#{release_name.join(', ')})') : %(-l release="#{release_name}") } - - specify do - expect(Gitlab::Popen).to receive(:popen_with_detail) - .with(["kubectl delete #{described_class::RESOURCE_LIST} " + - %(--namespace "#{namespace}" --now --ignore-not-found --wait=#{wait} #{release_names_in_command})]) - .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: true))) + context 'when no pvcs are stale' do + let(:pvc_1_created_at) { one_day_ago } + let(:pvc_2_created_at) { one_day_ago } - expect(Gitlab::Popen).to receive(:popen_with_detail) - .with([%(kubectl delete --namespace "#{namespace}" --ignore-not-found #{pod_for_release})]) - .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: true))) + it 'does not delete any PVC' do + expect(instance).not_to receive(:run_command).with(/kubectl delete pvc/) - # We're not verifying the output here, just silencing it - expect { subject.cleanup_by_release(release_name: release_name) }.to output.to_stdout + subject end end - it 'raises an error if the Kubernetes command fails' do - expect(Gitlab::Popen).to receive(:popen_with_detail) - .with(["kubectl delete #{described_class::RESOURCE_LIST} " + - %(--namespace "#{namespace}" --now --ignore-not-found --wait=true -l release="#{release_name}")]) - .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: false))) + context 'when some pvcs are stale' do + let(:pvc_1_created_at) { three_days_ago } + let(:pvc_2_created_at) { three_days_ago } - expect { subject.cleanup_by_release(release_name: release_name) }.to raise_error(described_class::CommandFailedError) - end + context 'when some pvcs are not in a review app namespaces' do + let(:pvc_1_namespace) { 'review-my-review-app' } + let(:pvc_2_namespace) { 'review-apps' } # This is not a review apps namespace, so we should not delete PVCs inside it - it_behaves_like 'a kubectl command to delete resources' + it 'deletes the stale pvcs inside of review-apps namespaces only' do + expect(instance).to receive(:run_command).with("kubectl delete pvc --namespace=#{pvc_1_namespace} --now --ignore-not-found pvc1") + expect(instance).not_to receive(:run_command).with(/kubectl delete pvc --namespace=#{pvc_2_namespace}/) - context 'with multiple releases' do - let(:release_name) { %w[my-release my-release-2] } + subject + end + end - it_behaves_like 'a kubectl command to delete resources' - end + context 'when all pvcs are in review-apps namespaces' do + let(:pvc_1_namespace) { 'review-my-review-app' } + let(:pvc_2_namespace) { 'review-another-review-app' } - context 'with `wait: false`' do - let(:wait) { false } + it 'deletes all of the stale pvcs' do + expect(instance).to receive(:run_command).with("kubectl delete pvc --namespace=#{pvc_1_namespace} --now --ignore-not-found pvc1") + expect(instance).to receive(:run_command).with("kubectl delete pvc --namespace=#{pvc_2_namespace} --now --ignore-not-found pvc2") - it_behaves_like 'a kubectl command to delete resources' + subject + end + end end end - describe '#cleanup_by_created_at' do - let(:two_days_ago) { Time.now - 3600 * 24 * 2 } - let(:resource_type) { 'pvc' } - let(:resource_names) { [pod_for_release] } + describe '#cleanup_namespaces_by_created_at' do + let(:namespace_1_created_at) { three_days_ago } + let(:namespace_2_created_at) { three_days_ago } + let(:namespace_1_name) { 'review-first-review-app' } + let(:namespace_2_name) { 'review-second-review-app' } + let(:kubectl_namespaces_json) do + <<~JSON + { + "apiVersion": "v1", + "items": [ + { + "apiVersion": "v1", + "kind": "namespace", + "metadata": { + "creationTimestamp": "#{namespace_1_created_at.utc.iso8601}", + "name": "#{namespace_1_name}" + } + }, + { + "apiVersion": "v1", + "kind": "namespace", + "metadata": { + "creationTimestamp": "#{namespace_2_created_at.utc.iso8601}", + "name": "#{namespace_2_name}" + } + } + ] + } + JSON + end + + subject { instance.cleanup_namespaces_by_created_at(created_before: two_days_ago) } before do - allow(subject).to receive(:resource_names_created_before).with(resource_type: resource_type, created_before: two_days_ago).and_return(resource_names) + allow(instance).to receive(:run_command).with( + "kubectl get namespace --all-namespaces --sort-by='{.metadata.creationTimestamp}' -o json" + ).and_return(kubectl_namespaces_json) end - shared_examples 'a kubectl command to delete resources by older than given creation time' do - let(:wait) { true } - let(:release_names_in_command) { resource_names.join(' ') } + context 'when no namespaces are stale' do + let(:namespace_1_created_at) { one_day_ago } + let(:namespace_2_created_at) { one_day_ago } - specify do - expect(Gitlab::Popen).to receive(:popen_with_detail) - .with(["kubectl delete #{resource_type} ".squeeze(' ') + - %(--namespace "#{namespace}" --now --ignore-not-found --wait=#{wait} #{release_names_in_command})]) - .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: true))) + it 'does not delete any namespace' do + expect(instance).not_to receive(:run_command).with(/kubectl delete namespace/) - # We're not verifying the output here, just silencing it - expect { subject.cleanup_by_created_at(resource_type: resource_type, created_before: two_days_ago) }.to output.to_stdout + subject end end - it 'raises an error if the Kubernetes command fails' do - expect(Gitlab::Popen).to receive(:popen_with_detail) - .with(["kubectl delete #{resource_type} " + - %(--namespace "#{namespace}" --now --ignore-not-found --wait=true #{pod_for_release})]) - .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: false))) + context 'when some namespaces are stale' do + let(:namespace_1_created_at) { three_days_ago } + let(:namespace_2_created_at) { three_days_ago } - expect { subject.cleanup_by_created_at(resource_type: resource_type, created_before: two_days_ago) }.to raise_error(described_class::CommandFailedError) - end + context 'when some namespaces are not review app namespaces' do + let(:namespace_1_name) { 'review-my-review-app' } + let(:namespace_2_name) { 'review-apps' } # This is not a review apps namespace, so we should not try to delete it - it_behaves_like 'a kubectl command to delete resources by older than given creation time' + it 'only deletes the review app namespaces' do + expect(instance).to receive(:run_command).with("kubectl delete namespace --now --ignore-not-found #{namespace_1_name}") - context 'with multiple resource names' do - let(:resource_names) { %w[pod-1 pod-2] } + subject + end + end - it_behaves_like 'a kubectl command to delete resources by older than given creation time' - end + context 'when all namespaces are review app namespaces' do + let(:namespace_1_name) { 'review-my-review-app' } + let(:namespace_2_name) { 'review-another-review-app' } - context 'with `wait: false`' do - let(:wait) { false } + it 'deletes all of the stale namespaces' do + expect(instance).to receive(:run_command).with("kubectl delete namespace --now --ignore-not-found #{namespace_1_name} #{namespace_2_name}") - it_behaves_like 'a kubectl command to delete resources by older than given creation time' + subject + end + end end + end - context 'with no resource_type given' do - let(:resource_type) { nil } + describe '#delete_pvc' do + let(:pvc_name) { 'my-pvc' } - it_behaves_like 'a kubectl command to delete resources by older than given creation time' - end + subject { instance.delete_pvc(pvc_name, pvc_namespace) } + + context 'when the namespace is not a review app namespace' do + let(:pvc_namespace) { 'not-a-review-app-namespace' } - context 'with multiple resource_type given' do - let(:resource_type) { 'pvc,service' } + it 'does not delete the pvc' do + expect(instance).not_to receive(:run_command).with(/kubectl delete pvc/) - it_behaves_like 'a kubectl command to delete resources by older than given creation time' + subject + end end - context 'with no resources found' do - let(:resource_names) { [] } + context 'when the namespace is a review app namespace' do + let(:pvc_namespace) { 'review-apple-test' } - it 'does not call #delete_by_exact_names' do - expect(subject).not_to receive(:delete_by_exact_names) + it 'deletes the pvc' do + expect(instance).to receive(:run_command).with("kubectl delete pvc --namespace=#{pvc_namespace} --now --ignore-not-found #{pvc_name}") - subject.cleanup_by_created_at(resource_type: resource_type, created_before: two_days_ago) + subject end end end - describe '#cleanup_review_app_namespaces' do - let(:two_days_ago) { Time.now - 3600 * 24 * 2 } - let(:namespaces) { %w[review-abc-123 review-xyz-789] } + describe '#delete_namespaces' do + subject { instance.delete_namespaces(namespaces) } - subject { described_class.new(namespace: nil) } + context 'when at least one namespace is not a review app namespace' do + let(:namespaces) { %w[review-ns-1 default] } - before do - allow(subject).to receive(:review_app_namespaces_created_before).with(created_before: two_days_ago).and_return(namespaces) + it 'does not delete any namespace' do + expect(instance).not_to receive(:run_command).with(/kubectl delete namespace/) + + subject + end end - shared_examples 'a kubectl command to delete namespaces older than given creation time' do - let(:wait) { true } + context 'when all namespaces are review app namespaces' do + let(:namespaces) { %w[review-ns-1 review-ns-2] } - specify do - expect(Gitlab::Popen).to receive(:popen_with_detail) - .with(["kubectl delete namespace " + - %(--now --ignore-not-found --wait=#{wait} #{namespaces.join(' ')})]) - .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: true))) + it 'deletes the namespaces' do + expect(instance).to receive(:run_command).with("kubectl delete namespace --now --ignore-not-found #{namespaces.join(' ')}") - # We're not verifying the output here, just silencing it - expect { subject.cleanup_review_app_namespaces(created_before: two_days_ago) }.to output.to_stdout + subject end end + end - it_behaves_like 'a kubectl command to delete namespaces older than given creation time' - - it 'raises an error if the Kubernetes command fails' do - expect(Gitlab::Popen).to receive(:popen_with_detail) - .with(["kubectl delete namespace " + - %(--now --ignore-not-found --wait=true #{namespaces.join(' ')})]) - .and_return(Gitlab::Popen::Result.new([], '', '', double(success?: false))) - - expect { subject.cleanup_review_app_namespaces(created_before: two_days_ago) }.to raise_error(described_class::CommandFailedError) + describe '#pvcs_created_before' do + subject { instance.pvcs_created_before(created_before: two_days_ago) } + + let(:pvc_1_created_at) { three_days_ago } + let(:pvc_2_created_at) { three_days_ago } + let(:pvc_1_namespace) { 'review-first-review-app' } + let(:pvc_2_namespace) { 'review-second-review-app' } + let(:kubectl_pvcs_json) do + <<~JSON + { + "apiVersion": "v1", + "items": [ + { + "apiVersion": "v1", + "kind": "PersistentVolumeClaim", + "metadata": { + "creationTimestamp": "#{pvc_1_created_at.utc.iso8601}", + "name": "pvc1", + "namespace": "#{pvc_1_namespace}" + } + }, + { + "apiVersion": "v1", + "kind": "PersistentVolumeClaim", + "metadata": { + "creationTimestamp": "#{pvc_2_created_at.utc.iso8601}", + "name": "pvc2", + "namespace": "#{pvc_2_namespace}" + } + } + ] + } + JSON end - context 'with no namespaces found' do - let(:namespaces) { [] } + it 'calls #resource_created_before with the correct parameters' do + expect(instance).to receive(:resource_created_before).with(resource_type: 'pvc', created_before: two_days_ago) - it 'does not call #delete_namespaces_by_exact_names' do - expect(subject).not_to receive(:delete_namespaces_by_exact_names) + subject + end - subject.cleanup_review_app_namespaces(created_before: two_days_ago) - end + it 'returns a hash with two keys' do + allow(instance).to receive(:run_command).with( + "kubectl get pvc --all-namespaces --sort-by='{.metadata.creationTimestamp}' -o json" + ).and_return(kubectl_pvcs_json) + + expect(subject).to match_array([ + { + resource_name: 'pvc1', + namespace: 'review-first-review-app' + }, + { + resource_name: 'pvc2', + namespace: 'review-second-review-app' + } + ]) end end - describe '#raw_resource_names' do - it 'calls kubectl to retrieve the resource names' do - expect(Gitlab::Popen).to receive(:popen_with_detail) - .with(["kubectl get #{described_class::RESOURCE_LIST} " + - %(--namespace "#{namespace}" -o name)]) - .and_return(Gitlab::Popen::Result.new([], raw_resource_names_str, '', double(success?: true))) - - expect(subject.__send__(:raw_resource_names)).to eq(raw_resource_names) + describe '#namespaces_created_before' do + subject { instance.namespaces_created_before(created_before: two_days_ago) } + + let(:namespace_1_created_at) { three_days_ago } + let(:namespace_2_created_at) { three_days_ago } + let(:namespace_1_name) { 'review-first-review-app' } + let(:namespace_2_name) { 'review-second-review-app' } + let(:kubectl_namespaces_json) do + <<~JSON + { + "apiVersion": "v1", + "items": [ + { + "apiVersion": "v1", + "kind": "namespace", + "metadata": { + "creationTimestamp": "#{namespace_1_created_at.utc.iso8601}", + "name": "#{namespace_1_name}" + } + }, + { + "apiVersion": "v1", + "kind": "namespace", + "metadata": { + "creationTimestamp": "#{namespace_2_created_at.utc.iso8601}", + "name": "#{namespace_2_name}" + } + } + ] + } + JSON end - end - describe '#resource_names_created_before' do - let(:three_days_ago) { Time.now - 3600 * 24 * 3 } - let(:two_days_ago) { Time.now - 3600 * 24 * 2 } - let(:pvc_created_three_days_ago) { 'pvc-created-three-days-ago' } - let(:resource_type) { 'pvc' } - let(:raw_resources) do - { - items: [ - { - apiVersion: "v1", - kind: "PersistentVolumeClaim", - metadata: { - creationTimestamp: three_days_ago, - name: pvc_created_three_days_ago - } - }, - { - apiVersion: "v1", - kind: "PersistentVolumeClaim", - metadata: { - creationTimestamp: Time.now, - name: 'another-pvc' - } - } - ] - }.to_json + it 'calls #resource_created_before with the correct parameters' do + expect(instance).to receive(:resource_created_before).with(resource_type: 'namespace', created_before: two_days_ago) + + subject end - shared_examples 'a kubectl command to retrieve resource names sorted by creationTimestamp' do - specify do - expect(Gitlab::Popen).to receive(:popen_with_detail) - .with(["kubectl get #{resource_type} ".squeeze(' ') + - %(--namespace "#{namespace}" ) + - "--sort-by='{.metadata.creationTimestamp}' -o json"]) - .and_return(Gitlab::Popen::Result.new([], raw_resources, '', double(success?: true))) + it 'returns an array of namespaces' do + allow(instance).to receive(:run_command).with( + "kubectl get namespace --all-namespaces --sort-by='{.metadata.creationTimestamp}' -o json" + ).and_return(kubectl_namespaces_json) - expect(subject.__send__(:resource_names_created_before, resource_type: resource_type, created_before: two_days_ago)).to contain_exactly(pvc_created_three_days_ago) - end + expect(subject).to match_array(%w[review-first-review-app review-second-review-app]) end + end - it_behaves_like 'a kubectl command to retrieve resource names sorted by creationTimestamp' + describe '#run_command' do + subject { instance.run_command(command) } - context 'with no resource_type given' do - let(:resource_type) { nil } + before do + # We undo the global mock just for this method + allow(instance).to receive(:run_command).and_call_original - it_behaves_like 'a kubectl command to retrieve resource names sorted by creationTimestamp' + # Mock stdout + allow(instance).to receive(:puts) end - context 'with multiple resource_type given' do - let(:resource_type) { 'pvc,service' } + context 'when executing a successful command' do + let(:command) { 'true' } # https://linux.die.net/man/1/true - it_behaves_like 'a kubectl command to retrieve resource names sorted by creationTimestamp' - end - end + it 'displays the name of the command to stdout' do + expect(instance).to receive(:puts).with("Running command: `#{command}`") + + subject + end - describe '#review_app_namespaces_created_before' do - let(:three_days_ago) { Time.now - 3600 * 24 * 3 } - let(:two_days_ago) { Time.now - 3600 * 24 * 2 } - let(:namespace_created_three_days_ago) { 'review-ns-created-three-days-ago' } - let(:resource_type) { 'namespace' } - let(:raw_resources) do - { - items: [ - { - apiVersion: "v1", - kind: "Namespace", - metadata: { - creationTimestamp: three_days_ago, - name: namespace_created_three_days_ago - } - }, - { - apiVersion: "v1", - kind: "Namespace", - metadata: { - creationTimestamp: Time.now, - name: 'another-namespace' - } - } - ] - }.to_json + it 'does not raise an error' do + expect { subject }.not_to raise_error + end end - specify do - expect(Gitlab::Popen).to receive(:popen_with_detail) - .with(["kubectl get namespace --sort-by='{.metadata.creationTimestamp}' -o json"]) - .and_return(Gitlab::Popen::Result.new([], raw_resources, '', double(success?: true))) + context 'when executing an unsuccessful command' do + let(:command) { 'false' } # https://linux.die.net/man/1/false + + it 'displays the name of the command to stdout' do + expect(instance).to receive(:puts).with("Running command: `#{command}`") - expect(subject.__send__(:review_app_namespaces_created_before, created_before: two_days_ago)).to eq([namespace_created_three_days_ago]) + expect { subject }.to raise_error(described_class::CommandFailedError) + end + + it 'raises an error' do + expect { subject }.to raise_error(described_class::CommandFailedError) + end end end end diff --git a/spec/tooling/lib/tooling/mappings/partial_to_views_mappings_spec.rb b/spec/tooling/lib/tooling/mappings/partial_to_views_mappings_spec.rb new file mode 100644 index 00000000000..69dddb0ae3d --- /dev/null +++ b/spec/tooling/lib/tooling/mappings/partial_to_views_mappings_spec.rb @@ -0,0 +1,274 @@ +# frozen_string_literal: true + +require 'tempfile' +require 'fileutils' +require_relative '../../../../../tooling/lib/tooling/mappings/partial_to_views_mappings' + +RSpec.describe Tooling::Mappings::PartialToViewsMappings, feature_category: :tooling do + attr_accessor :view_base_folder, :changes_file, :output_file + + let(:instance) { described_class.new(changes_file, output_file, view_base_folder: view_base_folder) } + let(:changes_file_content) { "changed_file1 changed_file2" } + let(:output_file_content) { "previously_added_view.html.haml" } + + around do |example| + self.changes_file = Tempfile.new('changes') + self.output_file = Tempfile.new('output_file') + + # See https://ruby-doc.org/stdlib-1.9.3/libdoc/tempfile/rdoc/ + # Tempfile.html#class-Tempfile-label-Explicit+close + begin + Dir.mktmpdir do |tmp_views_base_folder| + self.view_base_folder = tmp_views_base_folder + example.run + end + ensure + changes_file.close + output_file.close + changes_file.unlink + output_file.unlink + end + end + + before do + # We write into the temp files initially, to check how the code modified those files + File.write(changes_file, changes_file_content) + File.write(output_file, output_file_content) + end + + describe '#execute' do + subject { instance.execute } + + let(:changed_files) { ["#{view_base_folder}/my_view.html.haml"] } + let(:changes_file_content) { changed_files.join(" ") } + + before do + # We create all of the changed_files, so that they are part of the filtered files + changed_files.each { |changed_file| FileUtils.touch(changed_file) } + end + + it 'does not modify the content of the input file' do + expect { subject }.not_to change { File.read(changes_file) } + end + + context 'when no partials were modified' do + it 'empties the output file' do + expect { subject }.to change { File.read(output_file) }.from(output_file_content).to('') + end + end + + context 'when some partials were modified' do + let(:changed_files) do + [ + "#{view_base_folder}/my_view.html.haml", + "#{view_base_folder}/_my_partial.html.haml" + ] + end + + before do + # We create a red-herring partial to have a more convincing test suite + FileUtils.touch("#{view_base_folder}/_another_partial.html.haml") + end + + context 'when the partials are not included in any views' do + before do + File.write("#{view_base_folder}/my_view.html.haml", "render 'another_partial'") + end + + it 'empties the output file' do + expect { subject }.to change { File.read(output_file) }.from(output_file_content).to('') + end + end + + context 'when the partials are included in views' do + before do + File.write("#{view_base_folder}/my_view.html.haml", "render 'my_partial'") + end + + it 'writes the view including the partial to the output' do + expect { subject }.to change { File.read(output_file) } + .from(output_file_content) + .to("#{view_base_folder}/my_view.html.haml") + end + end + end + end + + describe '#filter_files' do + subject { instance.filter_files } + + let(:changes_file_content) { file_path } + + context 'when the file does not exist on disk' do + let(:file_path) { "#{view_base_folder}/_index.html.erb" } + + it 'returns an empty array' do + expect(subject).to be_empty + end + end + + context 'when the file exists on disk' do + before do + File.write(file_path, "I am a partial!") + end + + context 'when the file is not in the view base folders' do + let(:file_path) { "/tmp/_index.html.haml" } + + it 'returns an empty array' do + expect(subject).to be_empty + end + end + + context 'when the filename does not start with an underscore' do + let(:file_path) { "#{view_base_folder}/index.html.haml" } + + it 'returns an empty array' do + expect(subject).to be_empty + end + end + + context 'when the filename does not have the correct extension' do + let(:file_path) { "#{view_base_folder}/_index.html.erb" } + + it 'returns an empty array' do + expect(subject).to be_empty + end + end + + context 'when the file is a partial' do + let(:file_path) { "#{view_base_folder}/_index.html.haml" } + + it 'returns the file' do + expect(subject).to match_array(file_path) + end + end + end + end + + describe '#extract_partial_keyword' do + subject { instance.extract_partial_keyword('ee/app/views/shared/_new_project_item_vue_select.html.haml') } + + it 'returns the correct partial keyword' do + expect(subject).to eq('new_project_item_vue_select') + end + end + + describe '#view_includes_modified_partial?' do + subject { instance.view_includes_modified_partial?(view_file, included_partial_name) } + + context 'when the included partial name is relative to the view file' do + let(:view_file) { "#{view_base_folder}/components/my_view.html.haml" } + let(:included_partial_name) { 'subfolder/relative_partial' } + + before do + FileUtils.mkdir_p("#{view_base_folder}/components/subfolder") + File.write(changes_file_content, "I am a partial!") + end + + context 'when the partial is not part of the changed files' do + let(:changes_file_content) { "#{view_base_folder}/components/subfolder/_not_the_partial.html.haml" } + + it 'returns false' do + expect(subject).to be_falsey + end + end + + context 'when the partial is part of the changed files' do + let(:changes_file_content) { "#{view_base_folder}/components/subfolder/_relative_partial.html.haml" } + + it 'returns true' do + expect(subject).to be_truthy + end + end + end + + context 'when the included partial name is relative to the base views folder' do + let(:view_file) { "#{view_base_folder}/components/my_view.html.haml" } + let(:included_partial_name) { 'shared/absolute_partial' } + + before do + FileUtils.mkdir_p("#{view_base_folder}/components") + FileUtils.mkdir_p("#{view_base_folder}/shared") + File.write(changes_file_content, "I am a partial!") + end + + context 'when the partial is not part of the changed files' do + let(:changes_file_content) { "#{view_base_folder}/shared/not_the_partial" } + + it 'returns false' do + expect(subject).to be_falsey + end + end + + context 'when the partial is part of the changed files' do + let(:changes_file_content) { "#{view_base_folder}/shared/_absolute_partial.html.haml" } + + it 'returns true' do + expect(subject).to be_truthy + end + end + end + end + + describe '#reconstruct_partial_filename' do + subject { instance.reconstruct_partial_filename(partial_name) } + + context 'when the partial does not contain a path' do + let(:partial_name) { 'sidebar' } + + it 'returns the correct filename' do + expect(subject).to eq('_sidebar.html.haml') + end + end + + context 'when the partial contains a path' do + let(:partial_name) { 'shared/components/sidebar' } + + it 'returns the correct filename' do + expect(subject).to eq('shared/components/_sidebar.html.haml') + end + end + end + + describe '#find_pattern_in_file' do + let(:subject) { instance.find_pattern_in_file(file.path, /pattern/) } + let(:file) { Tempfile.new('find_pattern_in_file') } + + before do + file.write(file_content) + file.close + end + + context 'when the file contains the pattern' do + let(:file_content) do + <<~FILE + Beginning of file + + pattern + pattern + pattern + + End of file + FILE + end + + it 'returns the pattern once' do + expect(subject).to match_array(%w[pattern]) + end + end + + context 'when the file does not contain the pattern' do + let(:file_content) do + <<~FILE + Beginning of file + End of file + FILE + end + + it 'returns an empty array' do + expect(subject).to match_array([]) + end + end + end +end |