From 28515f6389dbf6feda2e489a3c7253fc56177a33 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Mon, 5 Sep 2022 18:12:16 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- .../components/legacy_pipeline_new_form.vue | 490 ++++++++++++++++++ app/assets/javascripts/pipeline_new/index.js | 81 ++- .../projects/pipelines_controller.rb | 1 + .../mutations/ci/job/artifacts_destroy.rb | 38 ++ app/graphql/types/mutation_type.rb | 1 + app/policies/ci/build_policy.rb | 2 + ..._branches.yml => run_pipeline_graphql.yml} | 10 +- ..._templates_total_unique_counts_monthly.yml | 1 + ...1145023_p_ci_templates_katalon_monthly.yml | 25 + ...i_templates_total_unique_counts_weekly.yml | 1 + ...31145014_p_ci_templates_katalon_weekly.yml | 25 + doc/api/graphql/reference/index.md | 20 + lib/api/branches.rb | 28 +- lib/gitlab/ci/templates/Katalon.gitlab-ci.yml | 65 +++ .../known_events/ci_templates.yml | 4 + qa/qa/page/project/pipeline/new.rb | 2 +- ...late_new_pipeline_vars_with_params_spec.rb | 44 +- .../pipelines/legacy_pipelines_spec.rb | 1 + .../projects/pipelines/pipelines_spec.rb | 61 ++- .../legacy_pipeline_new_form_spec.js | 456 ++++++++++++++++ .../templates/katalon_gitlab_ci_yaml_spec.rb | 52 ++ .../graphql/mutations/ci/job/destroy_spec.rb | 54 ++ 22 files changed, 1398 insertions(+), 64 deletions(-) create mode 100644 app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue create mode 100644 app/graphql/mutations/ci/job/artifacts_destroy.rb rename config/feature_flags/development/{api_caching_branches.yml => run_pipeline_graphql.yml} (63%) create mode 100644 config/metrics/counts_28d/20220531145023_p_ci_templates_katalon_monthly.yml create mode 100644 config/metrics/counts_7d/20220531145014_p_ci_templates_katalon_weekly.yml create mode 100644 lib/gitlab/ci/templates/Katalon.gitlab-ci.yml create mode 100644 spec/frontend/pipeline_new/components/legacy_pipeline_new_form_spec.js create mode 100644 spec/lib/gitlab/ci/templates/katalon_gitlab_ci_yaml_spec.rb create mode 100644 spec/requests/api/graphql/mutations/ci/job/destroy_spec.rb diff --git a/app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue new file mode 100644 index 00000000000..529ec4897b4 --- /dev/null +++ b/app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue @@ -0,0 +1,490 @@ + + + diff --git a/app/assets/javascripts/pipeline_new/index.js b/app/assets/javascripts/pipeline_new/index.js index 927eeb5e144..e3f363f4ada 100644 --- a/app/assets/javascripts/pipeline_new/index.js +++ b/app/assets/javascripts/pipeline_new/index.js @@ -1,22 +1,22 @@ import Vue from 'vue'; +import LegacyPipelineNewForm from './components/legacy_pipeline_new_form.vue'; import PipelineNewForm from './components/pipeline_new_form.vue'; -export default () => { - const el = document.getElementById('js-new-pipeline'); +const mountLegacyPipelineNewForm = (el) => { const { // provide/inject projectRefsEndpoint, // props - projectId, - pipelinesPath, configVariablesPath, defaultBranch, - refParam, - varParam, fileParam, - settingsLink, maxWarnings, + pipelinesPath, + projectId, + refParam, + settingsLink, + varParam, } = el.dataset; const variableParams = JSON.parse(varParam); @@ -28,19 +28,74 @@ export default () => { projectRefsEndpoint, }, render(createElement) { - return createElement(PipelineNewForm, { + return createElement(LegacyPipelineNewForm, { props: { - projectId, - pipelinesPath, configVariablesPath, defaultBranch, - refParam, - variableParams, fileParams, - settingsLink, maxWarnings: Number(maxWarnings), + pipelinesPath, + projectId, + refParam, + settingsLink, + variableParams, }, }); }, }); }; + +const mountPipelineNewForm = (el) => { + const { + // provide/inject + projectRefsEndpoint, + + // props + configVariablesPath, + defaultBranch, + fileParam, + maxWarnings, + pipelinesPath, + projectId, + refParam, + settingsLink, + varParam, + } = el.dataset; + + const variableParams = JSON.parse(varParam); + const fileParams = JSON.parse(fileParam); + + // TODO: add apolloProvider + + return new Vue({ + el, + provide: { + projectRefsEndpoint, + }, + render(createElement) { + return createElement(PipelineNewForm, { + props: { + configVariablesPath, + defaultBranch, + fileParams, + maxWarnings: Number(maxWarnings), + pipelinesPath, + projectId, + refParam, + settingsLink, + variableParams, + }, + }); + }, + }); +}; + +export default () => { + const el = document.getElementById('js-new-pipeline'); + + if (gon.features?.runPipelineGraphql) { + mountPipelineNewForm(el); + } else { + mountLegacyPipelineNewForm(el); + } +}; diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index c582d3f7285..51c3ed4e65b 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -26,6 +26,7 @@ class Projects::PipelinesController < Projects::ApplicationController before_action do push_frontend_feature_flag(:pipeline_tabs_vue, @project) + push_frontend_feature_flag(:run_pipeline_graphql, @project) end # Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596 diff --git a/app/graphql/mutations/ci/job/artifacts_destroy.rb b/app/graphql/mutations/ci/job/artifacts_destroy.rb new file mode 100644 index 00000000000..c27ab9c4d89 --- /dev/null +++ b/app/graphql/mutations/ci/job/artifacts_destroy.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module Job + class ArtifactsDestroy < Base + graphql_name 'JobArtifactsDestroy' + + authorize :destroy_artifacts + + field :job, + Types::Ci::JobType, + null: true, + description: 'Job with artifacts to be deleted.' + + field :destroyed_artifacts_count, + GraphQL::Types::Int, + null: false, + description: 'Number of artifacts deleted.' + + def find_object(id: ) + GlobalID::Locator.locate(id) + end + + def resolve(id:) + job = authorized_find!(id: id) + + result = ::Ci::JobArtifacts::DestroyBatchService.new(job.job_artifacts, pick_up_at: Time.current).execute + { + job: job, + destroyed_artifacts_count: result[:destroyed_artifacts_count], + errors: Array(result[:errors]) + } + end + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index e1806e5b19a..3cd7d612bc0 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -120,6 +120,7 @@ module Types milestone: '15.0' } mount_mutation Mutations::Ci::ProjectCiCdSettingsUpdate + mount_mutation Mutations::Ci::Job::ArtifactsDestroy mount_mutation Mutations::Ci::Job::Play mount_mutation Mutations::Ci::Job::Retry mount_mutation Mutations::Ci::Job::Cancel diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index f377ff85b5e..459ebed9791 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -2,6 +2,8 @@ module Ci class BuildPolicy < CommitStatusPolicy + delegate { @subject.project } + condition(:protected_ref) do access = ::Gitlab::UserAccess.new(@user, container: @subject.project) diff --git a/config/feature_flags/development/api_caching_branches.yml b/config/feature_flags/development/run_pipeline_graphql.yml similarity index 63% rename from config/feature_flags/development/api_caching_branches.yml rename to config/feature_flags/development/run_pipeline_graphql.yml index 310d643529e..78d8afbbee5 100644 --- a/config/feature_flags/development/api_caching_branches.yml +++ b/config/feature_flags/development/run_pipeline_graphql.yml @@ -1,8 +1,8 @@ --- -name: api_caching_branches -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61157 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/330371 -milestone: '13.12' +name: run_pipeline_graphql +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/96633 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/372310 +milestone: '15.4' type: development -group: group::source code +group: group::pipeline authoring default_enabled: false diff --git a/config/metrics/counts_28d/20210216184559_ci_templates_total_unique_counts_monthly.yml b/config/metrics/counts_28d/20210216184559_ci_templates_total_unique_counts_monthly.yml index a2a97eb7477..6f32243c8f8 100755 --- a/config/metrics/counts_28d/20210216184559_ci_templates_total_unique_counts_monthly.yml +++ b/config/metrics/counts_28d/20210216184559_ci_templates_total_unique_counts_monthly.yml @@ -172,6 +172,7 @@ options: - p_ci_templates_liquibase - p_ci_templates_matlab - p_ci_templates_themekit + - p_ci_templates_katalon distribution: - ce - ee diff --git a/config/metrics/counts_28d/20220531145023_p_ci_templates_katalon_monthly.yml b/config/metrics/counts_28d/20220531145023_p_ci_templates_katalon_monthly.yml new file mode 100644 index 00000000000..abbc30a5d10 --- /dev/null +++ b/config/metrics/counts_28d/20220531145023_p_ci_templates_katalon_monthly.yml @@ -0,0 +1,25 @@ +--- +key_path: redis_hll_counters.ci_templates.p_ci_templates_katalon_monthly +description: 'Monthly counts of times users have executed katalon_tests jobs' +product_section: 'ops' +product_stage: 'analytics' +product_group: 'pipeline_authoring' +product_category: 'pipeline_authoring' +value_type: number +status: active +milestone: "15.4" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/86484 +time_frame: 28d +data_source: redis_hll +data_category: optional +instrumentation_class: RedisHLLMetric +options: + events: + - p_ci_templates_katalon +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate diff --git a/config/metrics/counts_7d/20210216184557_ci_templates_total_unique_counts_weekly.yml b/config/metrics/counts_7d/20210216184557_ci_templates_total_unique_counts_weekly.yml index aa25ab379b9..faaf5be63a0 100755 --- a/config/metrics/counts_7d/20210216184557_ci_templates_total_unique_counts_weekly.yml +++ b/config/metrics/counts_7d/20210216184557_ci_templates_total_unique_counts_weekly.yml @@ -172,6 +172,7 @@ options: - p_ci_templates_liquibase - p_ci_templates_matlab - p_ci_templates_themekit + - p_ci_templates_katalon distribution: - ce - ee diff --git a/config/metrics/counts_7d/20220531145014_p_ci_templates_katalon_weekly.yml b/config/metrics/counts_7d/20220531145014_p_ci_templates_katalon_weekly.yml new file mode 100644 index 00000000000..f668646eacb --- /dev/null +++ b/config/metrics/counts_7d/20220531145014_p_ci_templates_katalon_weekly.yml @@ -0,0 +1,25 @@ +--- +key_path: redis_hll_counters.ci_templates.p_ci_templates_katalon_weekly +description: 'Weekly counts of times users have executed katalon_tests jobs' +product_section: 'ops' +product_stage: 'analytics' +product_group: 'pipeline_authoring' +product_category: 'pipeline_authoring' +value_type: number +status: active +milestone: "15.4" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/86484 +time_frame: 7d +data_source: redis_hll +data_category: optional +instrumentation_class: RedisHLLMetric +options: + events: + - p_ci_templates_katalon +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 45fa85b3454..f1ed7c5fc04 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -3463,6 +3463,26 @@ Input type: `JiraImportUsersInput` | `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | `jiraUsers` | [`[JiraUser!]`](#jirauser) | Users returned from Jira, matched by email and name if possible. | +### `Mutation.jobArtifactsDestroy` + +Input type: `JobArtifactsDestroyInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `id` | [`CiBuildID!`](#cibuildid) | ID of the job to mutate. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `destroyedArtifactsCount` | [`Int!`](#int) | Number of artifacts deleted. | +| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| `job` | [`CiJob`](#cijob) | Job with artifacts to be deleted. | + ### `Mutation.jobCancel` Input type: `JobCancelInput` diff --git a/lib/api/branches.rb b/lib/api/branches.rb index 446f24683a4..90db81cea1d 100644 --- a/lib/api/branches.rb +++ b/lib/api/branches.rb @@ -52,25 +52,15 @@ module API merged_branch_names = repository.merged_branch_names(branches.map(&:name)) - if Feature.enabled?(:api_caching_branches, user_project, type: :development) - present_cached( - branches, - with: Entities::Branch, - current_user: current_user, - project: user_project, - merged_branch_names: merged_branch_names, - expires_in: 10.minutes, - cache_context: -> (branch) { [current_user&.cache_key, merged_branch_names.include?(branch.name)] } - ) - else - present( - branches, - with: Entities::Branch, - current_user: current_user, - project: user_project, - merged_branch_names: merged_branch_names - ) - end + present_cached( + branches, + with: Entities::Branch, + current_user: current_user, + project: user_project, + merged_branch_names: merged_branch_names, + expires_in: 10.minutes, + cache_context: -> (branch) { [current_user&.cache_key, merged_branch_names.include?(branch.name)] } + ) end end diff --git a/lib/gitlab/ci/templates/Katalon.gitlab-ci.yml b/lib/gitlab/ci/templates/Katalon.gitlab-ci.yml new file mode 100644 index 00000000000..c8939c8f5a2 --- /dev/null +++ b/lib/gitlab/ci/templates/Katalon.gitlab-ci.yml @@ -0,0 +1,65 @@ +# This template is provided and maintained by Katalon, an official Technology Partner with GitLab. +# +# Use this template to run a Katalon Studio test from this repository. +# You can: +# - Copy and paste this template into a new `.gitlab-ci.yml` file. +# - Add this template to an existing `.gitlab-ci.yml` file by using the `include:` keyword. +# +# In either case, you must also select which job you want to run, `.katalon_tests` +# or `.katalon_tests_with_artifacts` (see configuration below), and add that configuration +# to a new job with `extends:`. For example: +# +# Katalon-tests: +# extends: +# - .katalon_tests_with_artifacts +# +# Requirements: +# - A Katalon Studio project with the content saved in the root GitLab repository folder. +# - An active KRE license. +# - A valid Katalon API key. +# +# CI/CD variables, set in the project CI/CD settings: +# - KATALON_TEST_SUITE_PATH: The default path is `Test Suites/`. +# Defines which test suite to run. +# - KATALON_API_KEY: The Katalon API key. +# - KATALON_PROJECT_DIR: Optional. Add if the project is in another location. +# - KATALON_ORG_ID: Optional. Add if you are part of multiple Katalon orgs. +# Set to the Org ID that has KRE licenses assigned. For more info on the Org ID, +# see https://support.katalon.com/hc/en-us/articles/4724459179545-How-to-get-Organization-ID- + +.katalon_tests: + # Use the latest version of the Katalon Runtime Engine. You can also use other versions of the + # Katalon Runtime Engine by specifying another tag, for example `katalonstudio/katalon:8.1.2` + # or `katalonstudio/katalon:8.3.0`. + image: 'katalonstudio/katalon' + services: + - docker:dind + variables: + # Specify the Katalon Studio project directory. By default, it is stored under the root project folder. + KATALON_PROJECT_DIR: $CI_PROJECT_DIR + + # The following bash script has two different versions, one if you set the KATALON_ORG_ID + # CI/CD variable, and the other if you did not set it. If you have more than one org in + # admin.katalon.com you must set the KATALON_ORG_ID variable with an ORG ID or + # the Katalon Test Suite fails to run. + # + # You can update or add additional `katalonc` commands below. To see all of the arguments + # `katalonc` supports, go to https://docs.katalon.com/katalon-studio/docs/console-mode-execution.html + script: + - |- + if [[ $KATALON_ORG_ID == "" ]]; then + katalonc.sh -projectPath=$KATALON_PROJECT_DIR -apiKey=$KATALON_API_KEY -browserType="Chrome" -retry=0 -statusDelay=20 -testSuitePath="$KATALON_TEST_SUITE_PATH" -reportFolder=Reports/ + else + katalonc.sh -projectPath=$KATALON_PROJECT_DIR -apiKey=$KATALON_API_KEY -browserType="Chrome" -retry=0 -statusDelay=20 -orgID=$KATALON_ORG_ID -testSuitePath="$KATALON_TEST_SUITE_PATH" -reportFolder=Reports/ + fi + +# Upload the artifacts and make the junit report accessible under the Pipeline Tests +.katalon_tests_with_artifacts: + extends: .katalon_tests + artifacts: + when: always + paths: + - Reports/ + reports: + junit: + Reports/*/*/*/*.xml diff --git a/lib/gitlab/usage_data_counters/known_events/ci_templates.yml b/lib/gitlab/usage_data_counters/known_events/ci_templates.yml index a8f1bab1f20..c4074f70d91 100644 --- a/lib/gitlab/usage_data_counters/known_events/ci_templates.yml +++ b/lib/gitlab/usage_data_counters/known_events/ci_templates.yml @@ -231,6 +231,10 @@ category: ci_templates redis_slot: ci_templates aggregation: weekly +- name: p_ci_templates_katalon + category: ci_templates + redis_slot: ci_templates + aggregation: weekly - name: p_ci_templates_mono category: ci_templates redis_slot: ci_templates diff --git a/qa/qa/page/project/pipeline/new.rb b/qa/qa/page/project/pipeline/new.rb index 6cf5c3b1134..742fcad5c07 100644 --- a/qa/qa/page/project/pipeline/new.rb +++ b/qa/qa/page/project/pipeline/new.rb @@ -5,7 +5,7 @@ module QA module Project module Pipeline class New < QA::Page::Base - view 'app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue' do + view 'app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue' do element :run_pipeline_button, required: true element :ci_variable_row_container element :ci_variable_key_field diff --git a/spec/features/populate_new_pipeline_vars_with_params_spec.rb b/spec/features/populate_new_pipeline_vars_with_params_spec.rb index 744543d1252..75fa8561235 100644 --- a/spec/features/populate_new_pipeline_vars_with_params_spec.rb +++ b/spec/features/populate_new_pipeline_vars_with_params_spec.rb @@ -7,24 +7,42 @@ RSpec.describe "Populate new pipeline CI variables with url params", :js do let(:project) { create(:project) } let(:page_path) { new_project_pipeline_path(project) } - before do - sign_in(user) - project.add_maintainer(user) + shared_examples 'form pre-filled with URL params' do + before do + sign_in(user) + project.add_maintainer(user) - visit "#{page_path}?var[key1]=value1&file_var[key2]=value2" - end + visit "#{page_path}?var[key1]=value1&file_var[key2]=value2" + end - it "var[key1]=value1 populates env_var variable correctly" do - page.within(all("[data-testid='ci-variable-row']")[0]) do - expect(find("[data-testid='pipeline-form-ci-variable-key']").value).to eq('key1') - expect(find("[data-testid='pipeline-form-ci-variable-value']").value).to eq('value1') + it "var[key1]=value1 populates env_var variable correctly" do + page.within(all("[data-testid='ci-variable-row']")[0]) do + expect(find("[data-testid='pipeline-form-ci-variable-key']").value).to eq('key1') + expect(find("[data-testid='pipeline-form-ci-variable-value']").value).to eq('value1') + end + end + + it "file_var[key2]=value2 populates file variable correctly" do + page.within(all("[data-testid='ci-variable-row']")[1]) do + expect(find("[data-testid='pipeline-form-ci-variable-key']").value).to eq('key2') + expect(find("[data-testid='pipeline-form-ci-variable-value']").value).to eq('value2') + end end end - it "file_var[key2]=value2 populates file variable correctly" do - page.within(all("[data-testid='ci-variable-row']")[1]) do - expect(find("[data-testid='pipeline-form-ci-variable-key']").value).to eq('key2') - expect(find("[data-testid='pipeline-form-ci-variable-value']").value).to eq('value2') + context 'when feature flag is disabled' do + before do + stub_feature_flags(run_pipeline_graphql: false) end + + it_behaves_like 'form pre-filled with URL params' + end + + context 'when feature flag is enabled' do + before do + stub_feature_flags(run_pipeline_graphql: true) + end + + it_behaves_like 'form pre-filled with URL params' end end diff --git a/spec/features/projects/pipelines/legacy_pipelines_spec.rb b/spec/features/projects/pipelines/legacy_pipelines_spec.rb index c903fe60fdb..2b3a6569c56 100644 --- a/spec/features/projects/pipelines/legacy_pipelines_spec.rb +++ b/spec/features/projects/pipelines/legacy_pipelines_spec.rb @@ -674,6 +674,7 @@ RSpec.describe 'Pipelines', :js do let(:project) { create(:project, :repository) } before do + stub_feature_flags(run_pipeline_graphql: false) visit new_project_pipeline_path(project) end diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index d4f58813534..d5705d1da04 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -656,19 +656,7 @@ RSpec.describe 'Pipelines', :js do describe 'POST /:project/-/pipelines' do let(:project) { create(:project, :repository) } - before do - visit new_project_pipeline_path(project) - end - - context 'for valid commit', :js do - before do - click_button project.default_branch - wait_for_requests - - find('p', text: 'master').click - wait_for_requests - end - + shared_examples 'run pipeline form with gitlab-ci.yml' do context 'with gitlab-ci.yml', :js do before do stub_ci_pipeline_to_return_yaml_file @@ -702,7 +690,9 @@ RSpec.describe 'Pipelines', :js do end end end + end + shared_examples 'run pipeline form without gitlab-ci.yml' do context 'without gitlab-ci.yml' do before do click_on 'Run pipeline' @@ -722,6 +712,51 @@ RSpec.describe 'Pipelines', :js do end end end + + # Run Pipeline form with REST endpoints + # TODO: Clean up tests when run_pipeline_graphql is enabled + context 'with feature flag disabled' do + before do + stub_feature_flags(run_pipeline_graphql: false) + visit new_project_pipeline_path(project) + end + + context 'for valid commit', :js do + before do + click_button project.default_branch + wait_for_requests + + find('p', text: 'master').click + wait_for_requests + end + + it_behaves_like 'run pipeline form with gitlab-ci.yml' + + it_behaves_like 'run pipeline form without gitlab-ci.yml' + end + end + + # Run Pipeline form with GraphQL + context 'with feature flag enabled' do + before do + stub_feature_flags(run_pipeline_graphql: true) + visit new_project_pipeline_path(project) + end + + context 'for valid commit', :js do + before do + click_button project.default_branch + wait_for_requests + + find('p', text: 'master').click + wait_for_requests + end + + it_behaves_like 'run pipeline form with gitlab-ci.yml' + + it_behaves_like 'run pipeline form without gitlab-ci.yml' + end + end end describe 'Reset runner caches' do diff --git a/spec/frontend/pipeline_new/components/legacy_pipeline_new_form_spec.js b/spec/frontend/pipeline_new/components/legacy_pipeline_new_form_spec.js new file mode 100644 index 00000000000..f2d2575c5fb --- /dev/null +++ b/spec/frontend/pipeline_new/components/legacy_pipeline_new_form_spec.js @@ -0,0 +1,456 @@ +import { GlForm, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { nextTick } from 'vue'; +import CreditCardValidationRequiredAlert from 'ee_component/billings/components/cc_validation_required_alert.vue'; +import { TEST_HOST } from 'helpers/test_constants'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; +import { redirectTo } from '~/lib/utils/url_utility'; +import LegacyPipelineNewForm from '~/pipeline_new/components/legacy_pipeline_new_form.vue'; +import RefsDropdown from '~/pipeline_new/components/refs_dropdown.vue'; +import { + mockQueryParams, + mockPostParams, + mockProjectId, + mockError, + mockRefs, + mockCreditCardValidationRequiredError, +} from '../mock_data'; + +jest.mock('~/lib/utils/url_utility', () => ({ + redirectTo: jest.fn(), +})); + +const projectRefsEndpoint = '/root/project/refs'; +const pipelinesPath = '/root/project/-/pipelines'; +const configVariablesPath = '/root/project/-/pipelines/config_variables'; +const newPipelinePostResponse = { id: 1 }; +const defaultBranch = 'main'; + +describe('Pipeline New Form', () => { + let wrapper; + let mock; + let dummySubmitEvent; + + const findForm = () => wrapper.find(GlForm); + const findRefsDropdown = () => wrapper.findComponent(RefsDropdown); + const findSubmitButton = () => wrapper.find('[data-testid="run_pipeline_button"]'); + const findVariableRows = () => wrapper.findAll('[data-testid="ci-variable-row"]'); + const findRemoveIcons = () => wrapper.findAll('[data-testid="remove-ci-variable-row"]'); + const findDropdowns = () => wrapper.findAll('[data-testid="pipeline-form-ci-variable-type"]'); + const findKeyInputs = () => wrapper.findAll('[data-testid="pipeline-form-ci-variable-key"]'); + const findValueInputs = () => wrapper.findAll('[data-testid="pipeline-form-ci-variable-value"]'); + const findErrorAlert = () => wrapper.find('[data-testid="run-pipeline-error-alert"]'); + const findWarningAlert = () => wrapper.find('[data-testid="run-pipeline-warning-alert"]'); + const findWarningAlertSummary = () => findWarningAlert().find(GlSprintf); + const findWarnings = () => wrapper.findAll('[data-testid="run-pipeline-warning"]'); + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findCCAlert = () => wrapper.findComponent(CreditCardValidationRequiredAlert); + const getFormPostParams = () => JSON.parse(mock.history.post[0].data); + + const selectBranch = (branch) => { + // Select a branch in the dropdown + findRefsDropdown().vm.$emit('input', { + shortName: branch, + fullName: `refs/heads/${branch}`, + }); + }; + + const createComponent = (props = {}, method = shallowMount) => { + wrapper = method(LegacyPipelineNewForm, { + provide: { + projectRefsEndpoint, + }, + propsData: { + projectId: mockProjectId, + pipelinesPath, + configVariablesPath, + defaultBranch, + refParam: defaultBranch, + settingsLink: '', + maxWarnings: 25, + ...props, + }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, {}); + mock.onGet(projectRefsEndpoint).reply(httpStatusCodes.OK, mockRefs); + + dummySubmitEvent = { + preventDefault: jest.fn(), + }; + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + + mock.restore(); + }); + + describe('Form', () => { + beforeEach(async () => { + createComponent(mockQueryParams, mount); + + mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, newPipelinePostResponse); + + await waitForPromises(); + }); + + it('displays the correct values for the provided query params', async () => { + expect(findDropdowns().at(0).props('text')).toBe('Variable'); + expect(findDropdowns().at(1).props('text')).toBe('File'); + expect(findRefsDropdown().props('value')).toEqual({ shortName: 'tag-1' }); + expect(findVariableRows()).toHaveLength(3); + }); + + it('displays a variable from provided query params', () => { + expect(findKeyInputs().at(0).element.value).toBe('test_var'); + expect(findValueInputs().at(0).element.value).toBe('test_var_val'); + }); + + it('displays an empty variable for the user to fill out', async () => { + expect(findKeyInputs().at(2).element.value).toBe(''); + expect(findValueInputs().at(2).element.value).toBe(''); + expect(findDropdowns().at(2).props('text')).toBe('Variable'); + }); + + it('does not display remove icon for last row', () => { + expect(findRemoveIcons()).toHaveLength(2); + }); + + it('removes ci variable row on remove icon button click', async () => { + findRemoveIcons().at(1).trigger('click'); + + await nextTick(); + + expect(findVariableRows()).toHaveLength(2); + }); + + it('creates blank variable on input change event', async () => { + const input = findKeyInputs().at(2); + input.element.value = 'test_var_2'; + input.trigger('change'); + + await nextTick(); + + expect(findVariableRows()).toHaveLength(4); + expect(findKeyInputs().at(3).element.value).toBe(''); + expect(findValueInputs().at(3).element.value).toBe(''); + }); + }); + + describe('Pipeline creation', () => { + beforeEach(async () => { + mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, newPipelinePostResponse); + + await waitForPromises(); + }); + + it('does not submit the native HTML form', async () => { + createComponent(); + + findForm().vm.$emit('submit', dummySubmitEvent); + + expect(dummySubmitEvent.preventDefault).toHaveBeenCalled(); + }); + + it('disables the submit button immediately after submitting', async () => { + createComponent(); + + expect(findSubmitButton().props('disabled')).toBe(false); + + findForm().vm.$emit('submit', dummySubmitEvent); + await waitForPromises(); + + expect(findSubmitButton().props('disabled')).toBe(true); + }); + + it('creates pipeline with full ref and variables', async () => { + createComponent(); + + findForm().vm.$emit('submit', dummySubmitEvent); + await waitForPromises(); + + expect(getFormPostParams().ref).toEqual(`refs/heads/${defaultBranch}`); + expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${newPipelinePostResponse.id}`); + }); + + it('creates a pipeline with short ref and variables from the query params', async () => { + createComponent(mockQueryParams); + + await waitForPromises(); + + findForm().vm.$emit('submit', dummySubmitEvent); + + await waitForPromises(); + + expect(getFormPostParams()).toEqual(mockPostParams); + expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${newPipelinePostResponse.id}`); + }); + }); + + describe('When the ref has been changed', () => { + beforeEach(async () => { + createComponent({}, mount); + + await waitForPromises(); + }); + it('variables persist between ref changes', async () => { + selectBranch('main'); + + await waitForPromises(); + + const mainInput = findKeyInputs().at(0); + mainInput.element.value = 'build_var'; + mainInput.trigger('change'); + + await nextTick(); + + selectBranch('branch-1'); + + await waitForPromises(); + + const branchOneInput = findKeyInputs().at(0); + branchOneInput.element.value = 'deploy_var'; + branchOneInput.trigger('change'); + + await nextTick(); + + selectBranch('main'); + + await waitForPromises(); + + expect(findKeyInputs().at(0).element.value).toBe('build_var'); + expect(findVariableRows().length).toBe(2); + + selectBranch('branch-1'); + + await waitForPromises(); + + expect(findKeyInputs().at(0).element.value).toBe('deploy_var'); + expect(findVariableRows().length).toBe(2); + }); + }); + + describe('when yml defines a variable', () => { + const mockYmlKey = 'yml_var'; + const mockYmlValue = 'yml_var_val'; + const mockYmlMultiLineValue = `A value + with multiple + lines`; + const mockYmlDesc = 'A var from yml.'; + + it('loading icon is shown when content is requested and hidden when received', async () => { + createComponent(mockQueryParams, mount); + + mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { + [mockYmlKey]: { + value: mockYmlValue, + description: mockYmlDesc, + }, + }); + + expect(findLoadingIcon().exists()).toBe(true); + + await waitForPromises(); + + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('multi-line strings are added to the value field without removing line breaks', async () => { + createComponent(mockQueryParams, mount); + + mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { + [mockYmlKey]: { + value: mockYmlMultiLineValue, + description: mockYmlDesc, + }, + }); + + await waitForPromises(); + + expect(findValueInputs().at(0).element.value).toBe(mockYmlMultiLineValue); + }); + + describe('with description', () => { + beforeEach(async () => { + createComponent(mockQueryParams, mount); + + mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { + [mockYmlKey]: { + value: mockYmlValue, + description: mockYmlDesc, + }, + }); + + await waitForPromises(); + }); + + it('displays all the variables', async () => { + expect(findVariableRows()).toHaveLength(4); + }); + + it('displays a variable from yml', () => { + expect(findKeyInputs().at(0).element.value).toBe(mockYmlKey); + expect(findValueInputs().at(0).element.value).toBe(mockYmlValue); + }); + + it('displays a variable from provided query params', () => { + expect(findKeyInputs().at(1).element.value).toBe('test_var'); + expect(findValueInputs().at(1).element.value).toBe('test_var_val'); + }); + + it('adds a description to the first variable from yml', () => { + expect(findVariableRows().at(0).text()).toContain(mockYmlDesc); + }); + + it('removes the description when a variable key changes', async () => { + findKeyInputs().at(0).element.value = 'yml_var_modified'; + findKeyInputs().at(0).trigger('change'); + + await nextTick(); + + expect(findVariableRows().at(0).text()).not.toContain(mockYmlDesc); + }); + }); + + describe('without description', () => { + beforeEach(async () => { + createComponent(mockQueryParams, mount); + + mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { + [mockYmlKey]: { + value: mockYmlValue, + description: null, + }, + yml_var2: { + value: 'yml_var2_val', + }, + yml_var3: { + description: '', + }, + }); + + await waitForPromises(); + }); + + it('displays all the variables', async () => { + expect(findVariableRows()).toHaveLength(3); + }); + }); + }); + + describe('Form errors and warnings', () => { + beforeEach(() => { + createComponent(); + }); + + describe('when the refs cannot be loaded', () => { + beforeEach(() => { + mock + .onGet(projectRefsEndpoint, { params: { search: '' } }) + .reply(httpStatusCodes.INTERNAL_SERVER_ERROR); + + findRefsDropdown().vm.$emit('loadingError'); + }); + + it('shows both an error alert', () => { + expect(findErrorAlert().exists()).toBe(true); + expect(findWarningAlert().exists()).toBe(false); + }); + }); + + describe('when the error response can be handled', () => { + beforeEach(async () => { + mock.onPost(pipelinesPath).reply(httpStatusCodes.BAD_REQUEST, mockError); + + findForm().vm.$emit('submit', dummySubmitEvent); + + await waitForPromises(); + }); + + it('shows both error and warning', () => { + expect(findErrorAlert().exists()).toBe(true); + expect(findWarningAlert().exists()).toBe(true); + }); + + it('shows the correct error', () => { + expect(findErrorAlert().text()).toBe(mockError.errors[0]); + }); + + it('shows the correct warning title', () => { + const { length } = mockError.warnings; + + expect(findWarningAlertSummary().attributes('message')).toBe(`${length} warnings found:`); + }); + + it('shows the correct amount of warnings', () => { + expect(findWarnings()).toHaveLength(mockError.warnings.length); + }); + + it('re-enables the submit button', () => { + expect(findSubmitButton().props('disabled')).toBe(false); + }); + + it('does not show the credit card validation required alert', () => { + expect(findCCAlert().exists()).toBe(false); + }); + + describe('when the error response is credit card validation required', () => { + beforeEach(async () => { + mock + .onPost(pipelinesPath) + .reply(httpStatusCodes.BAD_REQUEST, mockCreditCardValidationRequiredError); + + window.gon = { + subscriptions_url: TEST_HOST, + payment_form_url: TEST_HOST, + }; + + findForm().vm.$emit('submit', dummySubmitEvent); + + await waitForPromises(); + }); + + it('shows credit card validation required alert', () => { + expect(findErrorAlert().exists()).toBe(false); + expect(findCCAlert().exists()).toBe(true); + }); + + it('clears error and hides the alert on dismiss', async () => { + expect(findCCAlert().exists()).toBe(true); + expect(wrapper.vm.$data.error).toBe(mockCreditCardValidationRequiredError.errors[0]); + + findCCAlert().vm.$emit('dismiss'); + + await nextTick(); + + expect(findCCAlert().exists()).toBe(false); + expect(wrapper.vm.$data.error).toBe(null); + }); + }); + }); + + describe('when the error response cannot be handled', () => { + beforeEach(async () => { + mock + .onPost(pipelinesPath) + .reply(httpStatusCodes.INTERNAL_SERVER_ERROR, 'something went wrong'); + + findForm().vm.$emit('submit', dummySubmitEvent); + + await waitForPromises(); + }); + + it('re-enables the submit button', () => { + expect(findSubmitButton().props('disabled')).toBe(false); + }); + }); + }); +}); diff --git a/spec/lib/gitlab/ci/templates/katalon_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/katalon_gitlab_ci_yaml_spec.rb new file mode 100644 index 00000000000..5a62324da74 --- /dev/null +++ b/spec/lib/gitlab/ci/templates/katalon_gitlab_ci_yaml_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Katalon.gitlab-ci.yml' do + subject(:template) do + <<~YAML + include: + - template: 'Katalon.gitlab-ci.yml' + + katalon_tests_placeholder: + extends: .katalon_tests + stage: test + script: + - echo "katalon tests" + + katalon_tests_with_artifacts_placeholder: + extends: .katalon_tests_with_artifacts + stage: test + script: + - echo "katalon tests with artifacts" + YAML + end + + describe 'the created pipeline' do + let(:project) { create(:project, :custom_repo, files: { 'README.md' => '' }) } + let(:user) { project.first_owner } + + let(:service) { Ci::CreatePipelineService.new(project, user, ref: 'master' ) } + let(:pipeline) { service.execute!(:push).payload } + let(:build_names) { pipeline.builds.pluck(:name) } + + before do + stub_ci_pipeline_yaml_file(template) + end + + it 'create katalon tests jobs' do + expect(build_names).to match_array(%w[katalon_tests_placeholder katalon_tests_with_artifacts_placeholder]) + + expect(pipeline.builds.find_by(name: 'katalon_tests_placeholder').options).to include( + image: { name: 'katalonstudio/katalon' }, + services: [{ name: 'docker:dind' }] + ) + + expect(pipeline.builds.find_by(name: 'katalon_tests_with_artifacts_placeholder').options).to include( + image: { name: 'katalonstudio/katalon' }, + services: [{ name: 'docker:dind' }], + artifacts: { when: 'always', paths: ['Reports/'], reports: { junit: ['Reports/*/*/*/*.xml'] } } + ) + end + end +end diff --git a/spec/requests/api/graphql/mutations/ci/job/destroy_spec.rb b/spec/requests/api/graphql/mutations/ci/job/destroy_spec.rb new file mode 100644 index 00000000000..5855eb6bb51 --- /dev/null +++ b/spec/requests/api/graphql/mutations/ci/job/destroy_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'JobArtifactsDestroy' do + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:job) { create(:ci_build) } + + let(:mutation) do + variables = { + id: job.to_global_id.to_s + } + graphql_mutation(:job_artifacts_destroy, variables, <<~FIELDS) + job { + name + } + destroyedArtifactsCount + errors + FIELDS + end + + before do + create(:ci_job_artifact, :archive, job: job) + create(:ci_job_artifact, :junit, job: job) + end + + it 'returns an error if the user is not allowed to destroy the job artifacts' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_errors).not_to be_empty + expect(job.reload.job_artifacts.count).to be(2) + end + + it 'destroys the job artifacts and returns the expected data' do + job.project.add_maintainer(user) + expected_data = { + 'jobArtifactsDestroy' => { + 'errors' => [], + 'destroyedArtifactsCount' => 2, + 'job' => { + 'name' => job.name + } + } + } + + post_graphql_mutation(mutation, current_user: user) + + expect(response).to have_gitlab_http_status(:success) + expect(graphql_data).to eq(expected_data) + expect(job.reload.job_artifacts.count).to be(0) + end +end