diff options
Diffstat (limited to 'spec/lib/gitlab/ci')
34 files changed, 2376 insertions, 284 deletions
diff --git a/spec/lib/gitlab/ci/ansi2json/result_spec.rb b/spec/lib/gitlab/ci/ansi2json/result_spec.rb index 31c0da95f0a..b7b4d6de8b9 100644 --- a/spec/lib/gitlab/ci/ansi2json/result_spec.rb +++ b/spec/lib/gitlab/ci/ansi2json/result_spec.rb @@ -10,7 +10,7 @@ RSpec.describe Gitlab::Ci::Ansi2json::Result do { lines: [], state: state, append: false, truncated: false, offset: offset, stream: stream } end - subject { described_class.new(params) } + subject { described_class.new(**params) } describe '#size' do before do diff --git a/spec/lib/gitlab/ci/ansi2json/style_spec.rb b/spec/lib/gitlab/ci/ansi2json/style_spec.rb index d27a642ecf3..ff70ff69aaa 100644 --- a/spec/lib/gitlab/ci/ansi2json/style_spec.rb +++ b/spec/lib/gitlab/ci/ansi2json/style_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Ansi2json::Style do describe '#set?' do - subject { described_class.new(params).set? } + subject { described_class.new(**params).set? } context 'when fg color is set' do let(:params) { { fg: 'term-fg-black' } } @@ -44,7 +44,7 @@ RSpec.describe Gitlab::Ci::Ansi2json::Style do end describe 'update formats to mimic terminals' do - subject { described_class.new(params) } + subject { described_class.new(**params) } context 'when fg color present' do let(:params) { { fg: 'term-fg-black', mask: mask } } diff --git a/spec/lib/gitlab/ci/build/artifacts/metadata_spec.rb b/spec/lib/gitlab/ci/build/artifacts/metadata_spec.rb index 77b8aa1d591..efe99cd276c 100644 --- a/spec/lib/gitlab/ci/build/artifacts/metadata_spec.rb +++ b/spec/lib/gitlab/ci/build/artifacts/metadata_spec.rb @@ -142,7 +142,7 @@ RSpec.describe Gitlab::Ci::Build::Artifacts::Metadata do it 'reads expected number of entries' do stream = File.open(tmpfile.path) - metadata = described_class.new(stream, 'public', { recursive: true }) + metadata = described_class.new(stream, 'public', recursive: true) expect(metadata.find_entries!.count).to eq entry_count end diff --git a/spec/lib/gitlab/ci/build/rules/rule/clause_spec.rb b/spec/lib/gitlab/ci/build/rules/rule/clause_spec.rb new file mode 100644 index 00000000000..faede7a361f --- /dev/null +++ b/spec/lib/gitlab/ci/build/rules/rule/clause_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Build::Rules::Rule::Clause do + describe '.fabricate' do + using RSpec::Parameterized::TableSyntax + + let(:value) { 'some value' } + + subject { described_class.fabricate(type, value) } + + context 'when type is valid' do + where(:type, :result) do + 'changes' | Gitlab::Ci::Build::Rules::Rule::Clause::Changes + 'exists' | Gitlab::Ci::Build::Rules::Rule::Clause::Exists + 'if' | Gitlab::Ci::Build::Rules::Rule::Clause::If + end + + with_them do + it { is_expected.to be_instance_of(result) } + end + end + + context 'when type is invalid' do + let(:type) { 'when' } + + it { is_expected.to be_nil } + + context "when type is 'variables'" do + let(:type) { 'variables' } + + it { is_expected.to be_nil } + end + end + end +end diff --git a/spec/lib/gitlab/ci/build/rules_spec.rb b/spec/lib/gitlab/ci/build/rules_spec.rb index cbeae33fbcf..a1af5b75f87 100644 --- a/spec/lib/gitlab/ci/build/rules_spec.rb +++ b/spec/lib/gitlab/ci/build/rules_spec.rb @@ -104,7 +104,7 @@ RSpec.describe Gitlab::Ci::Build::Rules do context 'with one rule without any clauses' do let(:rule_list) { [{ when: 'manual', allow_failure: true }] } - it { is_expected.to eq(described_class::Result.new('manual', nil, true)) } + it { is_expected.to eq(described_class::Result.new('manual', nil, true, nil)) } end context 'with one matching rule' do @@ -171,7 +171,7 @@ RSpec.describe Gitlab::Ci::Build::Rules do context 'with matching rule' do let(:rule_list) { [{ if: '$VAR == null', allow_failure: true }] } - it { is_expected.to eq(described_class::Result.new('on_success', nil, true)) } + it { is_expected.to eq(described_class::Result.new('on_success', nil, true, nil)) } end context 'with non-matching rule' do @@ -180,18 +180,60 @@ RSpec.describe Gitlab::Ci::Build::Rules do it { is_expected.to eq(described_class::Result.new('never')) } end end + + context 'with variables' do + context 'with matching rule' do + let(:rule_list) { [{ if: '$VAR == null', variables: { MY_VAR: 'my var' } }] } + + it { is_expected.to eq(described_class::Result.new('on_success', nil, nil, { MY_VAR: 'my var' })) } + end + end end describe 'Gitlab::Ci::Build::Rules::Result' do let(:when_value) { 'on_success' } let(:start_in) { nil } let(:allow_failure) { nil } + let(:variables) { nil } - subject { Gitlab::Ci::Build::Rules::Result.new(when_value, start_in, allow_failure) } + subject(:result) do + Gitlab::Ci::Build::Rules::Result.new(when_value, start_in, allow_failure, variables) + end describe '#build_attributes' do + let(:seed_attributes) { {} } + + subject(:build_attributes) do + result.build_attributes(seed_attributes) + end + it 'compacts nil values' do - expect(subject.build_attributes).to eq(options: {}, when: 'on_success') + is_expected.to eq(options: {}, when: 'on_success') + end + + context 'when there are variables in rules' do + let(:variables) { { VAR1: 'new var 1', VAR3: 'var 3' } } + + context 'when there are seed variables' do + let(:seed_attributes) do + { yaml_variables: [{ key: 'VAR1', value: 'var 1', public: true }, + { key: 'VAR2', value: 'var 2', public: true }] } + end + + it 'returns yaml_variables with override' do + is_expected.to include( + yaml_variables: [{ key: 'VAR1', value: 'new var 1', public: true }, + { key: 'VAR2', value: 'var 2', public: true }, + { key: 'VAR3', value: 'var 3', public: true }] + ) + end + end + + context 'when there is not seed variables' do + it 'does not return yaml_variables' do + is_expected.not_to have_key(:yaml_variables) + end + end end end @@ -200,7 +242,7 @@ RSpec.describe Gitlab::Ci::Build::Rules do let!(:when_value) { 'never' } it 'returns false' do - expect(subject.pass?).to eq(false) + expect(result.pass?).to eq(false) end end @@ -208,7 +250,7 @@ RSpec.describe Gitlab::Ci::Build::Rules do let!(:when_value) { 'on_success' } it 'returns true' do - expect(subject.pass?).to eq(true) + expect(result.pass?).to eq(true) end end end diff --git a/spec/lib/gitlab/ci/config/entry/allow_failure_spec.rb b/spec/lib/gitlab/ci/config/entry/allow_failure_spec.rb new file mode 100644 index 00000000000..7aaad57f5cd --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/allow_failure_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Config::Entry::AllowFailure do + let(:entry) { described_class.new(config.deep_dup) } + let(:expected_config) { config } + + describe 'validations' do + context 'when entry config value is valid' do + shared_examples 'valid entry' do + describe '#value' do + it 'returns key value' do + expect(entry.value).to eq(expected_config) + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context 'with boolean values' do + it_behaves_like 'valid entry' do + let(:config) { true } + end + + it_behaves_like 'valid entry' do + let(:config) { false } + end + end + + context 'with hash values' do + it_behaves_like 'valid entry' do + let(:config) { { exit_codes: 137 } } + let(:expected_config) { { exit_codes: [137] } } + end + + it_behaves_like 'valid entry' do + let(:config) { { exit_codes: [42, 137] } } + end + end + end + + context 'when entry value is not valid' do + shared_examples 'invalid entry' do + describe '#valid?' do + it { expect(entry).not_to be_valid } + it { expect(entry.errors).to include(error_message) } + end + end + + context 'when it has a wrong type' do + let(:config) { [1] } + let(:error_message) do + 'allow failure config should be a hash or a boolean value' + end + + it_behaves_like 'invalid entry' + end + + context 'with string exit codes' do + let(:config) { { exit_codes: 'string' } } + let(:error_message) do + 'allow failure exit codes should be an array of integers or an integer' + end + + it_behaves_like 'invalid entry' + end + + context 'with array of strings as exit codes' do + let(:config) { { exit_codes: ['string 1', 'string 2'] } } + let(:error_message) do + 'allow failure exit codes should be an array of integers or an integer' + end + + it_behaves_like 'invalid entry' + end + + context 'when it has an extra keys' do + let(:config) { { extra: true } } + let(:error_message) do + 'allow failure config contains unknown keys: extra' + end + + it_behaves_like 'invalid entry' + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/bridge_spec.rb b/spec/lib/gitlab/ci/config/entry/bridge_spec.rb index 8b2e0410474..b3b7901074a 100644 --- a/spec/lib/gitlab/ci/config/entry/bridge_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/bridge_spec.rb @@ -227,6 +227,23 @@ RSpec.describe Gitlab::Ci::Config::Entry::Bridge do end end end + + context 'when bridge config contains exit_codes' do + let(:config) do + { script: 'rspec', allow_failure: { exit_codes: [42] } } + end + + describe '#valid?' do + it { is_expected.not_to be_valid } + end + + describe '#errors' do + it 'returns an error message' do + expect(subject.errors) + .to include(/allow failure should be a boolean value/) + end + end + end end describe '#manual_action?' do diff --git a/spec/lib/gitlab/ci/config/entry/image_spec.rb b/spec/lib/gitlab/ci/config/entry/image_spec.rb index c3d91057328..e810d65d560 100644 --- a/spec/lib/gitlab/ci/config/entry/image_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/image_spec.rb @@ -81,7 +81,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do context 'when configuration has ports' do let(:ports) { [{ number: 80, protocol: 'http', name: 'foobar' }] } let(:config) { { name: 'ruby:2.7', entrypoint: %w(/bin/sh run), ports: ports } } - let(:entry) { described_class.new(config, { with_image_ports: image_ports }) } + let(:entry) { described_class.new(config, with_image_ports: image_ports) } let(:image_ports) { false } context 'when with_image_ports metadata is not enabled' do diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb index e0e8bc93770..7834a1a94f2 100644 --- a/spec/lib/gitlab/ci/config/entry/job_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb @@ -670,6 +670,10 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do end describe '#ignored?' do + before do + entry.compose! + end + context 'when job is a manual action' do context 'when it is not specified if job is allowed to fail' do let(:config) do @@ -700,6 +704,16 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do expect(entry).not_to be_ignored end end + + context 'when job is dynamically allowed to fail' do + let(:config) do + { script: 'deploy', when: 'manual', allow_failure: { exit_codes: 42 } } + end + + it 'is not an ignored job' do + expect(entry).not_to be_ignored + end + end end context 'when job is not a manual action' do @@ -709,6 +723,10 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do it 'is not an ignored job' do expect(entry).not_to be_ignored end + + it 'does not return allow_failure' do + expect(entry.value.key?(:allow_failure_criteria)).to be_falsey + end end context 'when job is allowed to fail' do @@ -717,6 +735,10 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do it 'is an ignored job' do expect(entry).to be_ignored end + + it 'does not return allow_failure_criteria' do + expect(entry.value.key?(:allow_failure_criteria)).to be_falsey + end end context 'when job is not allowed to fail' do @@ -725,6 +747,32 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do it 'is not an ignored job' do expect(entry).not_to be_ignored end + + it 'does not return allow_failure_criteria' do + expect(entry.value.key?(:allow_failure_criteria)).to be_falsey + end + end + + context 'when job is dynamically allowed to fail' do + let(:config) { { script: 'deploy', allow_failure: { exit_codes: 42 } } } + + it 'is not an ignored job' do + expect(entry).not_to be_ignored + end + + it 'returns allow_failure_criteria' do + expect(entry.value[:allow_failure_criteria]).to match(exit_codes: [42]) + end + + context 'with ci_allow_failure_with_exit_codes disabled' do + before do + stub_feature_flags(ci_allow_failure_with_exit_codes: false) + end + + it 'does not return allow_failure_criteria' do + expect(entry.value.key?(:allow_failure_criteria)).to be_falsey + end + end end end end diff --git a/spec/lib/gitlab/ci/config/entry/need_spec.rb b/spec/lib/gitlab/ci/config/entry/need_spec.rb index 5a826bf8282..983e95fae42 100644 --- a/spec/lib/gitlab/ci/config/entry/need_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/need_spec.rb @@ -165,6 +165,45 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Need do end end + context 'with cross pipeline artifacts needs' do + context 'when pipeline is provided' do + context 'when job is provided' do + let(:config) { { job: 'job_name', pipeline: '$THE_PIPELINE_ID' } } + + it { is_expected.to be_valid } + + it 'sets artifacts:true by default' do + expect(need.value).to eq(job: 'job_name', pipeline: '$THE_PIPELINE_ID', artifacts: true) + end + + it 'sets the type as cross_dependency' do + expect(need.type).to eq(:cross_dependency) + end + end + + context 'when artifacts is provided' do + let(:config) { { job: 'job_name', pipeline: '$THE_PIPELINE_ID', artifacts: false } } + + it { is_expected.to be_valid } + + it 'returns the correct value' do + expect(need.value).to eq(job: 'job_name', pipeline: '$THE_PIPELINE_ID', artifacts: false) + end + end + end + + context 'when config contains not allowed keys' do + let(:config) { { job: 'job_name', pipeline: '$THE_PIPELINE_ID', something: 'else' } } + + it { is_expected.not_to be_valid } + + it 'returns an error' do + expect(need.errors) + .to contain_exactly('cross pipeline dependency config contains unknown keys: something') + end + end + end + context 'when need config is not a string or a hash' do let(:config) { :job_name } diff --git a/spec/lib/gitlab/ci/config/entry/needs_spec.rb b/spec/lib/gitlab/ci/config/entry/needs_spec.rb index f3b9d0c3c84..f11f2a56f5f 100644 --- a/spec/lib/gitlab/ci/config/entry/needs_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/needs_spec.rb @@ -6,7 +6,7 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Needs do subject(:needs) { described_class.new(config) } before do - needs.metadata[:allowed_needs] = %i[job] + needs.metadata[:allowed_needs] = %i[job cross_dependency] end describe 'validations' do @@ -66,6 +66,27 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Needs do end end end + + context 'with too many cross pipeline dependencies' do + let(:limit) { described_class::NEEDS_CROSS_PIPELINE_DEPENDENCIES_LIMIT } + + let(:config) do + Array.new(limit.next) do |index| + { pipeline: "$UPSTREAM_PIPELINE_#{index}", job: 'job-1' } + end + end + + describe '#valid?' do + it { is_expected.not_to be_valid } + end + + describe '#errors' do + it 'returns error about incorrect type' do + expect(needs.errors).to contain_exactly( + "needs config must be less than or equal to #{limit}") + end + end + end end describe '.compose!' do diff --git a/spec/lib/gitlab/ci/config/entry/processable_spec.rb b/spec/lib/gitlab/ci/config/entry/processable_spec.rb index ac8dd2a3267..aadf94365c6 100644 --- a/spec/lib/gitlab/ci/config/entry/processable_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/processable_spec.rb @@ -361,7 +361,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Processable do context 'when root yaml variables are used' do let(:variables) do Gitlab::Ci::Config::Entry::Variables.new( - A: 'root', C: 'root', D: 'root' + { A: 'root', C: 'root', D: 'root' } ).value end diff --git a/spec/lib/gitlab/ci/config/entry/root_spec.rb b/spec/lib/gitlab/ci/config/entry/root_spec.rb index 79716df6b60..54c7a5c3602 100644 --- a/spec/lib/gitlab/ci/config/entry/root_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/root_spec.rb @@ -32,7 +32,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do image: 'ruby:2.7', default: {}, services: ['postgres:9.1', 'mysql:5.5'], - variables: { VAR: 'root' }, + variables: { VAR: 'root', VAR2: { value: 'val 2', description: 'this is var 2' } }, after_script: ['make clean'], stages: %w(build pages release), cache: { key: 'k', untracked: true, paths: ['public/'] }, @@ -80,6 +80,10 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do .to eq 'List of external YAML files to include.' end + it 'sets correct variables value' do + expect(root.variables_value).to eq('VAR' => 'root', 'VAR2' => 'val 2') + end + describe '#leaf?' do it 'is not leaf' do expect(root).not_to be_leaf @@ -128,7 +132,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], stage: 'test', cache: { key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }, - variables: { 'VAR' => 'root' }, + variables: { 'VAR' => 'root', 'VAR2' => 'val 2' }, ignore: false, after_script: ['make clean'], only: { refs: %w[branches tags] }, @@ -142,7 +146,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], stage: 'test', cache: { key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }, - variables: { 'VAR' => 'root' }, + variables: { 'VAR' => 'root', 'VAR2' => 'val 2' }, ignore: false, after_script: ['make clean'], only: { refs: %w[branches tags] }, @@ -158,7 +162,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do services: [{ name: "postgres:9.1" }, { name: "mysql:5.5" }], cache: { key: "k", untracked: true, paths: ["public/"], policy: "pull-push", when: 'on_success' }, only: { refs: %w(branches tags) }, - variables: { 'VAR' => 'job' }, + variables: { 'VAR' => 'job', 'VAR2' => 'val 2' }, after_script: [], ignore: false, scheduling_type: :stage } diff --git a/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb b/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb index 4a43e6c9a86..d1bd22e5573 100644 --- a/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb @@ -339,6 +339,22 @@ RSpec.describe Gitlab::Ci::Config::Entry::Rules::Rule do end end end + + context 'with an invalid variables' do + let(:config) do + { if: '$THIS == "that"', variables: 'hello' } + end + + before do + subject.compose! + end + + it { is_expected.not_to be_valid } + + it 'returns an error about invalid variables:' do + expect(subject.errors).to include(/variables config should be a hash of key value pairs/) + end + end end context 'allow_failure: validation' do diff --git a/spec/lib/gitlab/ci/config/entry/service_spec.rb b/spec/lib/gitlab/ci/config/entry/service_spec.rb index ec137ef2ae4..2795cc9dddf 100644 --- a/spec/lib/gitlab/ci/config/entry/service_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/service_spec.rb @@ -96,7 +96,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Service do { name: 'postgresql:9.5', alias: 'db', command: %w(cmd run), entrypoint: %w(/bin/sh run), ports: ports } end - let(:entry) { described_class.new(config, { with_image_ports: image_ports }) } + let(:entry) { described_class.new(config, with_image_ports: image_ports) } let(:image_ports) { false } context 'when with_image_ports metadata is not enabled' do diff --git a/spec/lib/gitlab/ci/config/entry/services_spec.rb b/spec/lib/gitlab/ci/config/entry/services_spec.rb index e4f8a348d21..85e7f297b03 100644 --- a/spec/lib/gitlab/ci/config/entry/services_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/services_spec.rb @@ -38,7 +38,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Services do context 'when configuration has ports' do let(:ports) { [{ number: 80, protocol: 'http', name: 'foobar' }] } let(:config) { ['postgresql:9.5', { name: 'postgresql:9.1', alias: 'postgres_old', ports: ports }] } - let(:entry) { described_class.new(config, { with_image_ports: image_ports }) } + let(:entry) { described_class.new(config, with_image_ports: image_ports) } let(:image_ports) { false } context 'when with_image_ports metadata is not enabled' do diff --git a/spec/lib/gitlab/ci/config/entry/variables_spec.rb b/spec/lib/gitlab/ci/config/entry/variables_spec.rb index ac33f858f43..426a38e2ef7 100644 --- a/spec/lib/gitlab/ci/config/entry/variables_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/variables_spec.rb @@ -3,7 +3,9 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Config::Entry::Variables do - subject { described_class.new(config) } + let(:metadata) { {} } + + subject { described_class.new(config, metadata) } shared_examples 'valid config' do describe '#value' do @@ -71,7 +73,13 @@ RSpec.describe Gitlab::Ci::Config::Entry::Variables do { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' } end - it_behaves_like 'valid config' + it_behaves_like 'invalid config' + + context 'when metadata has use_value_data' do + let(:metadata) { { use_value_data: true } } + + it_behaves_like 'valid config' + end end context 'when entry value is an array' do @@ -80,32 +88,36 @@ RSpec.describe Gitlab::Ci::Config::Entry::Variables do it_behaves_like 'invalid config' end - context 'when entry value has hash with other key-pairs' do - let(:config) do - { 'VARIABLE_1' => { value: 'value 1', hello: 'variable 1' }, - 'VARIABLE_2' => 'value 2' } - end + context 'when metadata has use_value_data' do + let(:metadata) { { use_value_data: true } } - it_behaves_like 'invalid config' - end + context 'when entry value has hash with other key-pairs' do + let(:config) do + { 'VARIABLE_1' => { value: 'value 1', hello: 'variable 1' }, + 'VARIABLE_2' => 'value 2' } + end - context 'when entry config value has hash with nil description' do - let(:config) do - { 'VARIABLE_1' => { value: 'value 1', description: nil } } + it_behaves_like 'invalid config' end - it_behaves_like 'invalid config' - end + context 'when entry config value has hash with nil description' do + let(:config) do + { 'VARIABLE_1' => { value: 'value 1', description: nil } } + end - context 'when entry config value has hash without description' do - let(:config) do - { 'VARIABLE_1' => { value: 'value 1' } } + it_behaves_like 'invalid config' end - let(:result) do - { 'VARIABLE_1' => 'value 1' } - end + context 'when entry config value has hash without description' do + let(:config) do + { 'VARIABLE_1' => { value: 'value 1' } } + end - it_behaves_like 'valid config' + let(:result) do + { 'VARIABLE_1' => 'value 1' } + end + + it_behaves_like 'valid config' + end end end diff --git a/spec/lib/gitlab/ci/mask_secret_spec.rb b/spec/lib/gitlab/ci/mask_secret_spec.rb index 7b2d6b58518..7d950c86700 100644 --- a/spec/lib/gitlab/ci/mask_secret_spec.rb +++ b/spec/lib/gitlab/ci/mask_secret_spec.rb @@ -22,6 +22,10 @@ RSpec.describe Gitlab::Ci::MaskSecret do expect(mask('token', nil)).to eq('token') end + it 'does not change a bytesize of a value' do + expect(mask('token-ΓΌ/unicode', 'token-ΓΌ').bytesize).to eq 16 + end + def mask(value, token) subject.mask!(value.dup, token) end diff --git a/spec/lib/gitlab/ci/parsers/codequality/code_climate_spec.rb b/spec/lib/gitlab/ci/parsers/codequality/code_climate_spec.rb new file mode 100644 index 00000000000..c6b8cf2a985 --- /dev/null +++ b/spec/lib/gitlab/ci/parsers/codequality/code_climate_spec.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Parsers::Codequality::CodeClimate do + describe '#parse!' do + subject(:parse) { described_class.new.parse!(code_climate, codequality_report) } + + let(:codequality_report) { Gitlab::Ci::Reports::CodequalityReports.new } + let(:code_climate) do + [ + { + "categories": [ + "Complexity" + ], + "check_name": "argument_count", + "content": { + "body": "" + }, + "description": "Method `new_array` has 12 arguments (exceeds 4 allowed). Consider refactoring.", + "fingerprint": "15cdb5c53afd42bc22f8ca366a08d547", + "location": { + "path": "foo.rb", + "lines": { + "begin": 10, + "end": 10 + } + }, + "other_locations": [], + "remediation_points": 900000, + "severity": "major", + "type": "issue", + "engine_name": "structure" + } + ].to_json + end + + context "when data is code_climate style JSON" do + context "when there are no degradations" do + let(:code_climate) { [].to_json } + + it "returns a codequality report" do + expect { parse }.not_to raise_error + + expect(codequality_report.degradations_count).to eq(0) + end + end + + context "when there are degradations" do + it "returns a codequality report" do + expect { parse }.not_to raise_error + + expect(codequality_report.degradations_count).to eq(1) + end + end + end + + context "when data is not a valid JSON string" do + let(:code_climate) do + [ + { + "categories": [ + "Complexity" + ], + "check_name": "argument_count", + "content": { + "body": "" + }, + "description": "Method `new_array` has 12 arguments (exceeds 4 allowed). Consider refactoring.", + "fingerprint": "15cdb5c53afd42bc22f8ca366a08d547", + "location": { + "path": "foo.rb", + "lines": { + "begin": 10, + "end": 10 + } + }, + "other_locations": [], + "remediation_points": 900000, + "severity": "major", + "type": "issue", + "engine_name": "structure" + } + ] + end + + it "sets error_message" do + expect { parse }.not_to raise_error + + expect(codequality_report.error_message).to include('JSON parsing failed') + end + end + + context 'when degradations contain an invalid one' do + let(:code_climate) do + [ + { + "type": "Issue", + "check_name": "Rubocop/Metrics/ParameterLists", + "description": "Avoid parameter lists longer than 5 parameters. [12/5]", + "fingerprint": "ab5f8b935886b942d621399aefkaehfiaehf", + "severity": "minor" + }, + { + "categories": [ + "Complexity" + ], + "check_name": "argument_count", + "content": { + "body": "" + }, + "description": "Method `new_array` has 12 arguments (exceeds 4 allowed). Consider refactoring.", + "fingerprint": "15cdb5c53afd42bc22f8ca366a08d547", + "location": { + "path": "foo.rb", + "lines": { + "begin": 10, + "end": 10 + } + }, + "other_locations": [], + "remediation_points": 900000, + "severity": "major", + "type": "issue", + "engine_name": "structure" + } + ].to_json + end + + it 'stops parsing the report' do + expect { parse }.not_to raise_error + + expect(codequality_report.degradations_count).to eq(0) + expect(codequality_report.error_message).to eq("Invalid degradation format: The property '#/' did not contain a required property of 'location'") + end + end + end +end diff --git a/spec/lib/gitlab/ci/parsers/coverage/cobertura_spec.rb b/spec/lib/gitlab/ci/parsers/coverage/cobertura_spec.rb index 45e87466532..2313378d1e9 100644 --- a/spec/lib/gitlab/ci/parsers/coverage/cobertura_spec.rb +++ b/spec/lib/gitlab/ci/parsers/coverage/cobertura_spec.rb @@ -4,207 +4,690 @@ require 'fast_spec_helper' RSpec.describe Gitlab::Ci::Parsers::Coverage::Cobertura do describe '#parse!' do - subject { described_class.new.parse!(cobertura, coverage_report) } + subject(:parse_report) { described_class.new.parse!(cobertura, coverage_report, project_path: project_path, worktree_paths: paths) } let(:coverage_report) { Gitlab::Ci::Reports::CoverageReports.new } + let(:project_path) { 'foo/bar' } + let(:paths) { ['app/user.rb'] } + + let(:cobertura) do + <<~EOF + <coverage> + #{sources_xml} + #{classes_xml} + </coverage> + EOF + end context 'when data is Cobertura style XML' do - context 'when there is no <class>' do - let(:cobertura) { '' } + shared_examples_for 'ignoring sources, project_path, and worktree_paths' do + context 'when there is no <class>' do + let(:classes_xml) { '' } - it 'parses XML and returns empty coverage' do - expect { subject }.not_to raise_error + it 'parses XML and returns empty coverage' do + expect { parse_report }.not_to raise_error - expect(coverage_report.files).to eq({}) + expect(coverage_report.files).to eq({}) + end end - end - context 'when there is a <sources>' do - shared_examples_for 'ignoring sources' do - it 'parses XML without errors' do - expect { subject }.not_to raise_error + context 'when there is a single <class>' do + context 'with no lines' do + let(:classes_xml) do + <<~EOF + <packages><package name="app"><classes> + <class filename="app.rb"></class> + </classes></package></packages> + EOF + end + + it 'parses XML and returns empty coverage' do + expect { parse_report }.not_to raise_error + + expect(coverage_report.files).to eq({}) + end + end - expect(coverage_report.files).to eq({}) + context 'with a single line' do + let(:classes_xml) do + <<~EOF + <packages><package name="app"><classes> + <class filename="app.rb"><lines> + <line number="1" hits="2"/> + </lines></class> + </classes></package></packages> + EOF + end + + it 'parses XML and returns a single file with coverage' do + expect { parse_report }.not_to raise_error + + expect(coverage_report.files).to eq({ 'app.rb' => { 1 => 2 } }) + end + end + + context 'without a package parent' do + let(:classes_xml) do + <<~EOF + <packages> + <class filename="app.rb"><lines> + <line number="1" hits="2"/> + </lines></class> + </packages> + EOF + end + + it 'parses XML and returns a single file with coverage' do + expect { parse_report }.not_to raise_error + + expect(coverage_report.files).to eq({ 'app.rb' => { 1 => 2 } }) + end + end + + context 'with multiple lines and methods info' do + let(:classes_xml) do + <<~EOF + <packages><package name="app"><classes> + <class filename="app.rb"><methods/><lines> + <line number="1" hits="2"/> + <line number="2" hits="0"/> + </lines></class> + </classes></package></packages> + EOF + end + + it 'parses XML and returns a single file with coverage' do + expect { parse_report }.not_to raise_error + + expect(coverage_report.files).to eq({ 'app.rb' => { 1 => 2, 2 => 0 } }) + end end end - context 'and has a single source' do - let(:cobertura) do - <<-EOF.strip_heredoc + context 'when there are multiple <class>' do + context 'without a package parent' do + let(:classes_xml) do + <<~EOF + <packages> + <class filename="app.rb"><methods/><lines> + <line number="1" hits="2"/> + </lines></class> + <class filename="foo.rb"><methods/><lines> + <line number="6" hits="1"/> + </lines></class> + </packages> + EOF + end + + it 'parses XML and returns coverage information per class' do + expect { parse_report }.not_to raise_error + + expect(coverage_report.files).to eq({ 'app.rb' => { 1 => 2 }, 'foo.rb' => { 6 => 1 } }) + end + end + + context 'with the same filename and different lines' do + let(:classes_xml) do + <<~EOF + <packages><package name="app"><classes> + <class filename="app.rb"><methods/><lines> + <line number="1" hits="2"/> + <line number="2" hits="0"/> + </lines></class> + <class filename="app.rb"><methods/><lines> + <line number="6" hits="1"/> + <line number="7" hits="1"/> + </lines></class> + </classes></package></packages> + EOF + end + + it 'parses XML and returns a single file with merged coverage' do + expect { parse_report }.not_to raise_error + + expect(coverage_report.files).to eq({ 'app.rb' => { 1 => 2, 2 => 0, 6 => 1, 7 => 1 } }) + end + end + + context 'with the same filename and lines' do + let(:classes_xml) do + <<~EOF + <packages><package name="app"><classes> + <class filename="app.rb"><methods/><lines> + <line number="1" hits="2"/> + <line number="2" hits="0"/> + </lines></class> + <class filename="app.rb"><methods/><lines> + <line number="1" hits="1"/> + <line number="2" hits="1"/> + </lines></class> + </classes></package></packages> + EOF + end + + it 'parses XML and returns a single file with summed-up coverage' do + expect { parse_report }.not_to raise_error + + expect(coverage_report.files).to eq({ 'app.rb' => { 1 => 3, 2 => 1 } }) + end + end + + context 'with missing filename' do + let(:classes_xml) do + <<~EOF + <packages><package name="app"><classes> + <class filename="app.rb"><methods/><lines> + <line number="1" hits="2"/> + <line number="2" hits="0"/> + </lines></class> + <class><methods/><lines> + <line number="6" hits="1"/> + <line number="7" hits="1"/> + </lines></class> + </classes></package></packages> + EOF + end + + it 'parses XML and ignores class with missing name' do + expect { parse_report }.not_to raise_error + + expect(coverage_report.files).to eq({ 'app.rb' => { 1 => 2, 2 => 0 } }) + end + end + + context 'with invalid line information' do + let(:classes_xml) do + <<~EOF + <packages><package name="app"><classes> + <class filename="app.rb"><methods/><lines> + <line number="1" hits="2"/> + <line number="2" hits="0"/> + </lines></class> + <class filename="app.rb"><methods/><lines> + <line null="test" hits="1"/> + <line number="7" hits="1"/> + </lines></class> + </classes></package></packages> + EOF + end + + it 'raises an error' do + expect { parse_report }.to raise_error(described_class::InvalidLineInformationError) + end + end + end + end + + context 'when there is no <sources>' do + let(:sources_xml) { '' } + + it_behaves_like 'ignoring sources, project_path, and worktree_paths' + end + + context 'when there is a <sources>' do + context 'and has a single source with a pattern for Go projects' do + let(:project_path) { 'local/go' } # Make sure we're not making false positives + let(:sources_xml) do + <<~EOF <sources> - <source>project/src</source> + <source>/usr/local/go/src</source> </sources> EOF end - it_behaves_like 'ignoring sources' + it_behaves_like 'ignoring sources, project_path, and worktree_paths' end - context 'and has multiple sources' do - let(:cobertura) do - <<-EOF.strip_heredoc + context 'and has multiple sources with a pattern for Go projects' do + let(:project_path) { 'local/go' } # Make sure we're not making false positives + let(:sources_xml) do + <<~EOF <sources> - <source>project/src/foo</source> - <source>project/src/bar</source> + <source>/usr/local/go/src</source> + <source>/go/src</source> </sources> EOF end - it_behaves_like 'ignoring sources' + it_behaves_like 'ignoring sources, project_path, and worktree_paths' end - end - context 'when there is a single <class>' do - context 'with no lines' do - let(:cobertura) do - <<-EOF.strip_heredoc - <classes><class filename="app.rb"></class></classes> + context 'and has a single source but already is at the project root path' do + let(:sources_xml) do + <<~EOF + <sources> + <source>builds/#{project_path}</source> + </sources> EOF end - it 'parses XML and returns empty coverage' do - expect { subject }.not_to raise_error + it_behaves_like 'ignoring sources, project_path, and worktree_paths' + end - expect(coverage_report.files).to eq({}) + context 'and has multiple sources but already are at the project root path' do + let(:sources_xml) do + <<~EOF + <sources> + <source>builds/#{project_path}/</source> + <source>builds/somewhere/#{project_path}</source> + </sources> + EOF end + + it_behaves_like 'ignoring sources, project_path, and worktree_paths' end - context 'with a single line' do - let(:cobertura) do - <<-EOF.strip_heredoc - <classes> - <class filename="app.rb"><lines> - <line number="1" hits="2"/> - </lines></class> - </classes> + context 'and has a single source that is not at the project root path' do + let(:sources_xml) do + <<~EOF + <sources> + <source>builds/#{project_path}/app</source> + </sources> EOF end - it 'parses XML and returns a single file with coverage' do - expect { subject }.not_to raise_error + context 'when there is no <class>' do + let(:classes_xml) { '' } - expect(coverage_report.files).to eq({ 'app.rb' => { 1 => 2 } }) - end - end + it 'parses XML and returns empty coverage' do + expect { parse_report }.not_to raise_error - context 'with multipe lines and methods info' do - let(:cobertura) do - <<-EOF.strip_heredoc - <classes> - <class filename="app.rb"><methods/><lines> - <line number="1" hits="2"/> - <line number="2" hits="0"/> - </lines></class> - </classes> - EOF + expect(coverage_report.files).to eq({}) + end end - it 'parses XML and returns a single file with coverage' do - expect { subject }.not_to raise_error + context 'when there is a single <class>' do + context 'with no lines' do + let(:classes_xml) do + <<~EOF + <packages><package name="app"><classes> + <class filename="user.rb"></class> + </classes></package></packages> + EOF + end + + it 'parses XML and returns empty coverage' do + expect { parse_report }.not_to raise_error + + expect(coverage_report.files).to eq({}) + end + end + + context 'with a single line but the filename cannot be determined based on extracted source and worktree paths' do + let(:classes_xml) do + <<~EOF + <packages><package name="app"><classes> + <class filename="member.rb"><lines> + <line number="1" hits="2"/> + </lines></class> + </classes></package></packages> + EOF + end + + it 'parses XML and returns empty coverage' do + expect { parse_report }.not_to raise_error + + expect(coverage_report.files).to eq({}) + end + end + + context 'with a single line' do + let(:classes_xml) do + <<~EOF + <packages><package name="app"><classes> + <class filename="user.rb"><lines> + <line number="1" hits="2"/> + </lines></class> + </classes></package></packages> + EOF + end + + it 'parses XML and returns a single file with the filename relative to project root' do + expect { parse_report }.not_to raise_error + + expect(coverage_report.files).to eq({ 'app/user.rb' => { 1 => 2 } }) + end + end + + context 'with multiple lines and methods info' do + let(:classes_xml) do + <<~EOF + <packages><package name="app"><classes> + <class filename="user.rb"><methods/><lines> + <line number="1" hits="2"/> + <line number="2" hits="0"/> + </lines></class> + </classes></package></packages> + EOF + end + + it 'parses XML and returns a single file with the filename relative to project root' do + expect { parse_report }.not_to raise_error + + expect(coverage_report.files).to eq({ 'app/user.rb' => { 1 => 2, 2 => 0 } }) + end + end + end - expect(coverage_report.files).to eq({ 'app.rb' => { 1 => 2, 2 => 0 } }) + context 'when there are multiple <class>' do + context 'with the same filename but the filename cannot be determined based on extracted source and worktree paths' do + let(:classes_xml) do + <<~EOF + <packages><package name="app"><classes> + <class filename="member.rb"><methods/><lines> + <line number="1" hits="2"/> + <line number="2" hits="0"/> + </lines></class> + <class filename="member.rb"><methods/><lines> + <line number="6" hits="1"/> + <line number="7" hits="1"/> + </lines></class> + </classes></package></packages> + EOF + end + + it 'parses XML and returns empty coverage' do + expect { parse_report }.not_to raise_error + + expect(coverage_report.files).to eq({}) + end + end + + context 'without a parent package' do + let(:classes_xml) do + <<~EOF + <packages> + <class filename="user.rb"><methods/><lines> + <line number="1" hits="2"/> + <line number="2" hits="0"/> + </lines></class> + <class filename="user.rb"><methods/><lines> + <line number="6" hits="1"/> + <line number="7" hits="1"/> + </lines></class> + </packages> + EOF + end + + it 'parses XML and returns coverage information with the filename relative to project root' do + expect { parse_report }.not_to raise_error + + expect(coverage_report.files).to eq({ 'app/user.rb' => { 1 => 2, 2 => 0, 6 => 1, 7 => 1 } }) + end + end + + context 'with the same filename and different lines' do + let(:classes_xml) do + <<~EOF + <packages><package name="app"><classes> + <class filename="user.rb"><methods/><lines> + <line number="1" hits="2"/> + <line number="2" hits="0"/> + </lines></class> + <class filename="user.rb"><methods/><lines> + <line number="6" hits="1"/> + <line number="7" hits="1"/> + </lines></class> + </classes></package></packages> + EOF + end + + it 'parses XML and returns a single file with merged coverage, and with the filename relative to project root' do + expect { parse_report }.not_to raise_error + + expect(coverage_report.files).to eq({ 'app/user.rb' => { 1 => 2, 2 => 0, 6 => 1, 7 => 1 } }) + end + end + + context 'with the same filename and lines' do + let(:classes_xml) do + <<~EOF + <packages><package name="app"><classes> + <class filename="user.rb"><methods/><lines> + <line number="1" hits="2"/> + <line number="2" hits="0"/> + </lines></class> + <class filename="user.rb"><methods/><lines> + <line number="1" hits="1"/> + <line number="2" hits="1"/> + </lines></class> + </classes></package></packages> + EOF + end + + it 'parses XML and returns a single file with summed-up coverage, and with the filename relative to project root' do + expect { parse_report }.not_to raise_error + + expect(coverage_report.files).to eq({ 'app/user.rb' => { 1 => 3, 2 => 1 } }) + end + end + + context 'with missing filename' do + let(:classes_xml) do + <<~EOF + <packages><package name="app"><classes> + <class filename="user.rb"><methods/><lines> + <line number="1" hits="2"/> + <line number="2" hits="0"/> + </lines></class> + <class><methods/><lines> + <line number="6" hits="1"/> + <line number="7" hits="1"/> + </lines></class> + </classes></package></packages> + EOF + end + + it 'parses XML and ignores class with missing name' do + expect { parse_report }.not_to raise_error + + expect(coverage_report.files).to eq({ 'app/user.rb' => { 1 => 2, 2 => 0 } }) + end + end + + context 'with filename that cannot be determined based on extracted source and worktree paths' do + let(:classes_xml) do + <<~EOF + <packages><package name="app"><classes> + <class filename="user.rb"><methods/><lines> + <line number="1" hits="2"/> + <line number="2" hits="0"/> + </lines></class> + <class filename="member.rb"><methods/><lines> + <line number="6" hits="1"/> + <line number="7" hits="1"/> + </lines></class> + </classes></package></packages> + EOF + end + + it 'parses XML and ignores class with undetermined filename' do + expect { parse_report }.not_to raise_error + + expect(coverage_report.files).to eq({ 'app/user.rb' => { 1 => 2, 2 => 0 } }) + end + end + + context 'with invalid line information' do + let(:classes_xml) do + <<~EOF + <packages><package name="app"><classes> + <class filename="user.rb"><methods/><lines> + <line number="1" hits="2"/> + <line number="2" hits="0"/> + </lines></class> + <class filename="user.rb"><methods/><lines> + <line null="test" hits="1"/> + <line number="7" hits="1"/> + </lines></class> + </classes></package></packages> + EOF + end + + it 'raises an error' do + expect { parse_report }.to raise_error(described_class::InvalidLineInformationError) + end + end end end - end - context 'when there are multipe <class>' do - context 'with the same filename and different lines' do - let(:cobertura) do - <<-EOF.strip_heredoc - <classes> - <class filename="app.rb"><methods/><lines> - <line number="1" hits="2"/> - <line number="2" hits="0"/> - </lines></class> - <class filename="app.rb"><methods/><lines> - <line number="6" hits="1"/> - <line number="7" hits="1"/> - </lines></class> - </classes> + context 'and has multiple sources that are not at the project root path' do + let(:sources_xml) do + <<~EOF + <sources> + <source>builds/#{project_path}/app1/</source> + <source>builds/#{project_path}/app2/</source> + </sources> EOF end - it 'parses XML and returns a single file with merged coverage' do - expect { subject }.not_to raise_error - - expect(coverage_report.files).to eq({ 'app.rb' => { 1 => 2, 2 => 0, 6 => 1, 7 => 1 } }) + context 'and a class filename is available under multiple extracted sources' do + let(:paths) { ['app1/user.rb', 'app2/user.rb'] } + + let(:classes_xml) do + <<~EOF + <package name="app1"> + <classes> + <class filename="user.rb"><lines> + <line number="1" hits="2"/> + </lines></class> + </classes> + </package> + <package name="app2"> + <classes> + <class filename="user.rb"><lines> + <line number="2" hits="3"/> + </lines></class> + </classes> + </package> + EOF + end + + it 'parses XML and returns the files with the filename relative to project root' do + expect { parse_report }.not_to raise_error + + expect(coverage_report.files).to eq({ + 'app1/user.rb' => { 1 => 2 }, + 'app2/user.rb' => { 2 => 3 } + }) + end end - end - context 'with the same filename and lines' do - let(:cobertura) do - <<-EOF.strip_heredoc - <packages><package><classes> - <class filename="app.rb"><methods/><lines> - <line number="1" hits="2"/> - <line number="2" hits="0"/> - </lines></class> - <class filename="app.rb"><methods/><lines> - <line number="1" hits="1"/> - <line number="2" hits="1"/> - </lines></class> - </classes></package></packages> - EOF + context 'and a class filename is available under one of the extracted sources' do + let(:paths) { ['app1/member.rb', 'app2/user.rb', 'app2/pet.rb'] } + + let(:classes_xml) do + <<~EOF + <packages><package name="app"><classes> + <class filename="user.rb"><lines> + <line number="1" hits="2"/> + </lines></class> + </classes></package></packages> + EOF + end + + it 'parses XML and returns a single file with the filename relative to project root using the extracted source where it is first found under' do + expect { parse_report }.not_to raise_error + + expect(coverage_report.files).to eq({ 'app2/user.rb' => { 1 => 2 } }) + end end - it 'parses XML and returns a single file with summed-up coverage' do - expect { subject }.not_to raise_error + context 'and a class filename is not found under any of the extracted sources' do + let(:paths) { ['app1/member.rb', 'app2/pet.rb'] } - expect(coverage_report.files).to eq({ 'app.rb' => { 1 => 3, 2 => 1 } }) + let(:classes_xml) do + <<~EOF + <packages><package name="app"><classes> + <class filename="user.rb"><lines> + <line number="1" hits="2"/> + </lines></class> + </classes></package></packages> + EOF + end + + it 'parses XML and returns empty coverage' do + expect { parse_report }.not_to raise_error + + expect(coverage_report.files).to eq({}) + end end - end - context 'with missing filename' do - let(:cobertura) do - <<-EOF.strip_heredoc - <classes> - <class filename="app.rb"><methods/><lines> - <line number="1" hits="2"/> - <line number="2" hits="0"/> - </lines></class> - <class><methods/><lines> - <line number="6" hits="1"/> - <line number="7" hits="1"/> - </lines></class> - </classes> - EOF + context 'and a class filename is not found under any of the extracted sources within the iteratable limit' do + let(:paths) { ['app2/user.rb'] } + + let(:classes_xml) do + <<~EOF + <packages><package name="app"><classes> + <class filename="record.rb"><lines> + <line number="1" hits="2"/> + </lines></class> + <class filename="user.rb"><lines> + <line number="1" hits="2"/> + </lines></class> + </classes></package></packages> + EOF + end + + before do + stub_const("#{described_class}::MAX_SOURCES", 1) + end + + it 'parses XML and returns empty coverage' do + expect { parse_report }.not_to raise_error + + expect(coverage_report.files).to eq({}) + end end + end + end - it 'parses XML and ignores class with missing name' do - expect { subject }.not_to raise_error + shared_examples_for 'non-smart parsing' do + let(:sources_xml) do + <<~EOF + <sources> + <source>builds/foo/bar/app</source> + </sources> + EOF + end - expect(coverage_report.files).to eq({ 'app.rb' => { 1 => 2, 2 => 0 } }) - end + let(:classes_xml) do + <<~EOF + <packages><package name="app"><classes> + <class filename="user.rb"><lines> + <line number="1" hits="2"/> + </lines></class> + </classes></package></packages> + EOF end - context 'with invalid line information' do - let(:cobertura) do - <<-EOF.strip_heredoc - <classes> - <class filename="app.rb"><methods/><lines> - <line number="1" hits="2"/> - <line number="2" hits="0"/> - </lines></class> - <class filename="app.rb"><methods/><lines> - <line null="test" hits="1"/> - <line number="7" hits="1"/> - </lines></class> - </classes> - EOF - end + it 'parses XML and returns filenames unchanged just as how they are found in the class node' do + expect { parse_report }.not_to raise_error - it 'raises an error' do - expect { subject }.to raise_error(described_class::CoberturaParserError) - end + expect(coverage_report.files).to eq({ 'user.rb' => { 1 => 2 } }) end end + + context 'when project_path is not present' do + let(:project_path) { nil } + let(:paths) { ['app/user.rb'] } + + it_behaves_like 'non-smart parsing' + end + + context 'when worktree_paths is not present' do + let(:project_path) { 'foo/bar' } + let(:paths) { nil } + + it_behaves_like 'non-smart parsing' + end end context 'when data is not Cobertura style XML' do let(:cobertura) { { coverage: '12%' }.to_json } it 'raises an error' do - expect { subject }.to raise_error(described_class::CoberturaParserError) + expect { parse_report }.to raise_error(described_class::InvalidXMLError) end end end diff --git a/spec/lib/gitlab/ci/parsers_spec.rb b/spec/lib/gitlab/ci/parsers_spec.rb index db9a5775d9f..b932cd81272 100644 --- a/spec/lib/gitlab/ci/parsers_spec.rb +++ b/spec/lib/gitlab/ci/parsers_spec.rb @@ -30,6 +30,14 @@ RSpec.describe Gitlab::Ci::Parsers do end end + context 'when file_type is codequality' do + let(:file_type) { 'codequality' } + + it 'fabricates the class' do + is_expected.to be_a(described_class::Codequality::CodeClimate) + end + end + context 'when file_type is terraform' do let(:file_type) { 'terraform' } diff --git a/spec/lib/gitlab/ci/pipeline/chain/limit/deployments_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/limit/deployments_spec.rb new file mode 100644 index 00000000000..78363be7f36 --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/chain/limit/deployments_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Gitlab::Ci::Pipeline::Chain::Limit::Deployments do + let_it_be(:namespace) { create(:namespace) } + let_it_be(:project, reload: true) { create(:project, namespace: namespace) } + let_it_be(:plan_limits, reload: true) { create(:plan_limits, :default_plan) } + + let(:pipeline_seed) { double(:pipeline_seed, deployments_count: 2) } + let(:save_incompleted) { false } + + let(:command) do + double(:command, + project: project, + pipeline_seed: pipeline_seed, + save_incompleted: save_incompleted + ) + end + + let(:pipeline) { build(:ci_pipeline, project: project) } + let(:step) { described_class.new(pipeline, command) } + + subject(:perform) { step.perform! } + + context 'when pipeline deployments limit is exceeded' do + before do + plan_limits.update!(ci_pipeline_deployments: 1) + end + + context 'when saving incompleted pipelines' do + let(:save_incompleted) { true } + + it 'drops the pipeline' do + perform + + expect(pipeline).to be_persisted + expect(pipeline.reload).to be_failed + end + + it 'breaks the chain' do + perform + + expect(step.break?).to be true + end + + it 'sets a valid failure reason' do + perform + + expect(pipeline.deployments_limit_exceeded?).to be true + end + end + + context 'when not saving incomplete pipelines' do + let(:save_incompleted) { false } + + it 'does not persist the pipeline' do + perform + + expect(pipeline).not_to be_persisted + end + + it 'breaks the chain' do + perform + + expect(step.break?).to be true + end + + it 'adds an informative error to the pipeline' do + perform + + expect(pipeline.errors.messages).to include(base: ['Pipeline has too many deployments! Requested 2, but the limit is 1.']) + end + end + + it 'logs the error' do + expect(Gitlab::ErrorTracking).to receive(:track_exception).with( + instance_of(Gitlab::Ci::Limit::LimitExceededError), + project_id: project.id, plan: namespace.actual_plan_name + ) + + perform + end + end + + context 'when pipeline deployments limit is not exceeded' do + before do + plan_limits.update!(ci_pipeline_deployments: 100) + end + + it 'does not break the chain' do + perform + + expect(step.break?).to be false + end + + it 'does not invalidate the pipeline' do + perform + + expect(pipeline.errors).to be_empty + end + + it 'does not log any error' do + expect(Gitlab::ErrorTracking).not_to receive(:track_exception) + + perform + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/chain/seed_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/seed_spec.rb index d849c768a3c..0ce8b80902e 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/seed_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/seed_spec.rb @@ -50,8 +50,8 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Seed do it 'sets the seeds in the command object' do run_chain - expect(command.stage_seeds).to all(be_a Gitlab::Ci::Pipeline::Seed::Base) - expect(command.stage_seeds.count).to eq 1 + expect(command.pipeline_seed).to be_a(Gitlab::Ci::Pipeline::Seed::Pipeline) + expect(command.pipeline_seed.size).to eq 1 end context 'when no ref policy is specified' do @@ -63,16 +63,18 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Seed do } end - it 'correctly fabricates a stage seeds object' do + it 'correctly fabricates stages and builds' do run_chain - seeds = command.stage_seeds - expect(seeds.size).to eq 2 - expect(seeds.first.attributes[:name]).to eq 'test' - expect(seeds.second.attributes[:name]).to eq 'deploy' - expect(seeds.dig(0, 0, :name)).to eq 'rspec' - expect(seeds.dig(0, 1, :name)).to eq 'spinach' - expect(seeds.dig(1, 0, :name)).to eq 'production' + seed = command.pipeline_seed + + expect(seed.stages.size).to eq 2 + expect(seed.size).to eq 3 + expect(seed.stages.first.name).to eq 'test' + expect(seed.stages.second.name).to eq 'deploy' + expect(seed.stages[0].statuses[0].name).to eq 'rspec' + expect(seed.stages[0].statuses[1].name).to eq 'spinach' + expect(seed.stages[1].statuses[0].name).to eq 'production' end end @@ -88,14 +90,14 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Seed do } end - it 'returns stage seeds only assigned to master' do + it 'returns pipeline seed with jobs only assigned to master' do run_chain - seeds = command.stage_seeds + seed = command.pipeline_seed - expect(seeds.size).to eq 1 - expect(seeds.first.attributes[:name]).to eq 'test' - expect(seeds.dig(0, 0, :name)).to eq 'spinach' + expect(seed.size).to eq 1 + expect(seed.stages.first.name).to eq 'test' + expect(seed.stages[0].statuses[0].name).to eq 'spinach' end end @@ -109,14 +111,14 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Seed do } end - it 'returns stage seeds only assigned to schedules' do + it 'returns pipeline seed with jobs only assigned to schedules' do run_chain - seeds = command.stage_seeds + seed = command.pipeline_seed - expect(seeds.size).to eq 1 - expect(seeds.first.attributes[:name]).to eq 'test' - expect(seeds.dig(0, 0, :name)).to eq 'spinach' + expect(seed.size).to eq 1 + expect(seed.stages.first.name).to eq 'test' + expect(seed.stages[0].statuses[0].name).to eq 'spinach' end end @@ -141,11 +143,11 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Seed do it 'returns seeds for kubernetes dependent job' do run_chain - seeds = command.stage_seeds + seed = command.pipeline_seed - expect(seeds.size).to eq 2 - expect(seeds.dig(0, 0, :name)).to eq 'spinach' - expect(seeds.dig(1, 0, :name)).to eq 'production' + expect(seed.size).to eq 2 + expect(seed.stages[0].statuses[0].name).to eq 'spinach' + expect(seed.stages[1].statuses[0].name).to eq 'production' end end end @@ -154,10 +156,10 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Seed do it 'does not return seeds for kubernetes dependent job' do run_chain - seeds = command.stage_seeds + seed = command.pipeline_seed - expect(seeds.size).to eq 1 - expect(seeds.dig(0, 0, :name)).to eq 'spinach' + expect(seed.size).to eq 1 + expect(seed.stages[0].statuses[0].name).to eq 'spinach' end end end @@ -173,10 +175,10 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Seed do it 'returns stage seeds only when variables expression is truthy' do run_chain - seeds = command.stage_seeds + seed = command.pipeline_seed - expect(seeds.size).to eq 1 - expect(seeds.dig(0, 0, :name)).to eq 'unit' + expect(seed.size).to eq 1 + expect(seed.stages[0].statuses[0].name).to eq 'unit' end end diff --git a/spec/lib/gitlab/ci/pipeline/quota/deployments_spec.rb b/spec/lib/gitlab/ci/pipeline/quota/deployments_spec.rb new file mode 100644 index 00000000000..c52994fc6a2 --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/quota/deployments_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Pipeline::Quota::Deployments do + let_it_be(:namespace) { create(:namespace) } + let_it_be(:default_plan, reload: true) { create(:default_plan) } + let_it_be(:project, reload: true) { create(:project, :repository, namespace: namespace) } + let_it_be(:plan_limits) { create(:plan_limits, plan: default_plan) } + + let(:pipeline) { build_stubbed(:ci_pipeline, project: project) } + + let(:pipeline_seed) { double(:pipeline_seed, deployments_count: 2)} + + let(:command) do + double(:command, + project: project, + pipeline_seed: pipeline_seed, + save_incompleted: true + ) + end + + let(:ci_pipeline_deployments_limit) { 0 } + + before do + plan_limits.update!(ci_pipeline_deployments: ci_pipeline_deployments_limit) + end + + subject(:quota) { described_class.new(namespace, pipeline, command) } + + shared_context 'limit exceeded' do + let(:ci_pipeline_deployments_limit) { 1 } + end + + shared_context 'limit not exceeded' do + let(:ci_pipeline_deployments_limit) { 2 } + end + + describe '#enabled?' do + context 'when limit is enabled in plan' do + let(:ci_pipeline_deployments_limit) { 10 } + + it 'is enabled' do + expect(quota).to be_enabled + end + end + + context 'when limit is not enabled' do + let(:ci_pipeline_deployments_limit) { 0 } + + it 'is not enabled' do + expect(quota).not_to be_enabled + end + end + + context 'when limit does not exist' do + before do + allow(namespace).to receive(:actual_plan) { create(:default_plan) } + end + + it 'is enabled by default' do + expect(quota).to be_enabled + end + end + end + + describe '#exceeded?' do + context 'when limit is exceeded' do + include_context 'limit exceeded' + + it 'is exceeded' do + expect(quota).to be_exceeded + end + end + + context 'when limit is not exceeded' do + include_context 'limit not exceeded' + + it 'is not exceeded' do + expect(quota).not_to be_exceeded + end + end + end + + describe '#message' do + context 'when limit is exceeded' do + include_context 'limit exceeded' + + it 'returns info about pipeline deployment limit exceeded' do + expect(quota.message) + .to eq "Pipeline has too many deployments! Requested 2, but the limit is 1." + end + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb index 0b961336f3f..bc10e94c81d 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb @@ -71,6 +71,33 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do end end + context 'with job:rules:[variables:]' do + let(:attributes) do + { name: 'rspec', + ref: 'master', + yaml_variables: [{ key: 'VAR1', value: 'var 1', public: true }, + { key: 'VAR2', value: 'var 2', public: true }], + rules: [{ if: '$VAR == null', variables: { VAR1: 'new var 1', VAR3: 'var 3' } }] } + end + + it do + is_expected.to include(yaml_variables: [{ key: 'VAR1', value: 'new var 1', public: true }, + { key: 'VAR2', value: 'var 2', public: true }, + { key: 'VAR3', value: 'var 3', public: true }]) + end + + context 'when FF ci_rules_variables is disabled' do + before do + stub_feature_flags(ci_rules_variables: false) + end + + it do + is_expected.to include(yaml_variables: [{ key: 'VAR1', value: 'var 1', public: true }, + { key: 'VAR2', value: 'var 2', public: true }]) + end + end + end + context 'with cache:key' do let(:attributes) do { @@ -165,6 +192,45 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do it { is_expected.to include(options: {}) } end + + context 'with allow_failure' do + let(:options) do + { allow_failure_criteria: { exit_codes: [42] } } + end + + let(:rules) do + [{ if: '$VAR == null', when: 'always' }] + end + + let(:attributes) do + { + name: 'rspec', + ref: 'master', + options: options, + rules: rules + } + end + + context 'when rules does not override allow_failure' do + it { is_expected.to match a_hash_including(options: options) } + end + + context 'when rules set allow_failure to true' do + let(:rules) do + [{ if: '$VAR == null', when: 'always', allow_failure: true }] + end + + it { is_expected.to match a_hash_including(options: { allow_failure_criteria: nil }) } + end + + context 'when rules set allow_failure to false' do + let(:rules) do + [{ if: '$VAR == null', when: 'always', allow_failure: false }] + end + + it { is_expected.to match a_hash_including(options: { allow_failure_criteria: nil }) } + end + end end describe '#bridge?' do diff --git a/spec/lib/gitlab/ci/pipeline/seed/environment_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/environment_spec.rb index e62bf042fba..664aaaedf7b 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/environment_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/environment_spec.rb @@ -85,16 +85,6 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Environment do end it_behaves_like 'returning a correct environment' - - context 'but the environment auto_stop_in on create flag is disabled' do - let(:expected_auto_stop_in) { nil } - - before do - stub_feature_flags(environment_auto_stop_start_on_create: false) - end - - it_behaves_like 'returning a correct environment' - end end end diff --git a/spec/lib/gitlab/ci/pipeline/seed/pipeline_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/pipeline_spec.rb new file mode 100644 index 00000000000..1790388da03 --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/seed/pipeline_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Pipeline::Seed::Pipeline do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:pipeline) { create(:ci_pipeline, project: project) } + + let(:stages_attributes) do + [ + { + name: 'build', + index: 0, + builds: [ + { name: 'init', scheduling_type: :stage }, + { name: 'build', scheduling_type: :stage } + ] + }, + { + name: 'test', + index: 1, + builds: [ + { name: 'rspec', scheduling_type: :stage }, + { name: 'staging', scheduling_type: :stage, environment: 'staging' }, + { name: 'deploy', scheduling_type: :stage, environment: 'production' } + ] + } + ] + end + + subject(:seed) do + described_class.new(pipeline, stages_attributes) + end + + describe '#stages' do + it 'returns the stage resources' do + stages = seed.stages + + expect(stages).to all(be_a(Ci::Stage)) + expect(stages.map(&:name)).to contain_exactly('build', 'test') + end + end + + describe '#size' do + it 'returns the number of jobs' do + expect(seed.size).to eq(5) + end + end + + describe '#errors' do + context 'when attributes are valid' do + it 'returns nil' do + expect(seed.errors).to be_nil + end + end + + context 'when attributes are not valid' do + it 'returns the errors' do + stages_attributes[0][:builds] << { + name: 'invalid_job', + scheduling_type: :dag, + needs_attributes: [{ name: 'non-existent', artifacts: true }] + } + + expect(seed.errors).to contain_exactly("invalid_job: needs 'non-existent'") + end + end + end + + describe '#deployments_count' do + it 'counts the jobs having an environment associated' do + expect(seed.deployments_count).to eq(2) + end + end +end diff --git a/spec/lib/gitlab/ci/reports/accessibility_reports_comparer_spec.rb b/spec/lib/gitlab/ci/reports/accessibility_reports_comparer_spec.rb index 650ae41320b..ade0e36cf1e 100644 --- a/spec/lib/gitlab/ci/reports/accessibility_reports_comparer_spec.rb +++ b/spec/lib/gitlab/ci/reports/accessibility_reports_comparer_spec.rb @@ -3,9 +3,9 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Reports::AccessibilityReportsComparer do - let(:comparer) { described_class.new(base_reports, head_reports) } - let(:base_reports) { Gitlab::Ci::Reports::AccessibilityReports.new } - let(:head_reports) { Gitlab::Ci::Reports::AccessibilityReports.new } + let(:comparer) { described_class.new(base_report, head_report) } + let(:base_report) { Gitlab::Ci::Reports::AccessibilityReports.new } + let(:head_report) { Gitlab::Ci::Reports::AccessibilityReports.new } let(:url) { "https://gitlab.com" } let(:single_error) do [ @@ -38,233 +38,254 @@ RSpec.describe Gitlab::Ci::Reports::AccessibilityReportsComparer do end describe '#status' do - subject { comparer.status } + subject(:status) { comparer.status } context 'when head report has an error' do before do - head_reports.add_url(url, single_error) + head_report.add_url(url, single_error) end it 'returns status failed' do - expect(subject).to eq(described_class::STATUS_FAILED) + expect(status).to eq(described_class::STATUS_FAILED) end end context 'when head reports does not have errors' do before do - head_reports.add_url(url, []) + head_report.add_url(url, []) end it 'returns status success' do - expect(subject).to eq(described_class::STATUS_SUCCESS) + expect(status).to eq(described_class::STATUS_SUCCESS) end end end describe '#errors_count' do - subject { comparer.errors_count } + subject(:errors_count) { comparer.errors_count } context 'when head report has an error' do before do - head_reports.add_url(url, single_error) + head_report.add_url(url, single_error) end it 'returns the number of new errors' do - expect(subject).to eq(1) + expect(errors_count).to eq(1) end end context 'when head reports does not have an error' do before do - head_reports.add_url(url, []) + head_report.add_url(url, []) end it 'returns the number new errors' do - expect(subject).to eq(0) + expect(errors_count).to eq(0) end end end describe '#resolved_count' do - subject { comparer.resolved_count } + subject(:resolved_count) { comparer.resolved_count } context 'when base reports has an error and head has a different error' do before do - base_reports.add_url(url, single_error) - head_reports.add_url(url, different_error) + base_report.add_url(url, single_error) + head_report.add_url(url, different_error) end it 'returns the resolved count' do - expect(subject).to eq(1) + expect(resolved_count).to eq(1) end end context 'when base reports has errors head has no errors' do before do - base_reports.add_url(url, single_error) - head_reports.add_url(url, []) + base_report.add_url(url, single_error) + head_report.add_url(url, []) end it 'returns the resolved count' do - expect(subject).to eq(1) + expect(resolved_count).to eq(1) end end context 'when base reports has errors and head has the same error' do before do - base_reports.add_url(url, single_error) - head_reports.add_url(url, single_error) + base_report.add_url(url, single_error) + head_report.add_url(url, single_error) end it 'returns zero' do - expect(subject).to eq(0) + expect(resolved_count).to eq(0) end end context 'when base reports does not have errors and head has errors' do before do - head_reports.add_url(url, single_error) + head_report.add_url(url, single_error) end it 'returns the number of resolved errors' do - expect(subject).to eq(0) + expect(resolved_count).to eq(0) end end end describe '#total_count' do - subject { comparer.total_count } + subject(:total_count) { comparer.total_count } context 'when base reports has an error' do before do - base_reports.add_url(url, single_error) + base_report.add_url(url, single_error) end - it 'returns the error count' do - expect(subject).to eq(1) + it 'returns zero' do + expect(total_count).to be_zero end end context 'when head report has an error' do before do - head_reports.add_url(url, single_error) + head_report.add_url(url, single_error) end - it 'returns the error count' do - expect(subject).to eq(1) + it 'returns the total count' do + expect(total_count).to eq(1) end end context 'when base report has errors and head report has errors' do before do - base_reports.add_url(url, single_error) - head_reports.add_url(url, different_error) + base_report.add_url(url, single_error) + head_report.add_url(url, different_error) + end + + it 'returns the total count' do + expect(total_count).to eq(1) + end + end + + context 'when base report has errors and head report has the same error' do + before do + base_report.add_url(url, single_error) + head_report.add_url(url, single_error + different_error) end - it 'returns the error count' do - expect(subject).to eq(2) + it 'returns the total count' do + expect(total_count).to eq(2) end end end describe '#existing_errors' do - subject { comparer.existing_errors } + subject(:existing_errors) { comparer.existing_errors } context 'when base report has errors and head has a different error' do before do - base_reports.add_url(url, single_error) - head_reports.add_url(url, different_error) + base_report.add_url(url, single_error) + head_report.add_url(url, different_error) end - it 'returns the existing errors' do - expect(subject.size).to eq(1) - expect(subject.first["code"]).to eq("WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent") + it 'returns an empty array' do + expect(existing_errors).to be_empty end end context 'when base report does not have errors and head has errors' do before do - base_reports.add_url(url, []) - head_reports.add_url(url, single_error) + base_report.add_url(url, []) + head_report.add_url(url, single_error) end it 'returns an empty array' do - expect(subject).to be_empty + expect(existing_errors).to be_empty + end + end + + context 'when base report has errors and head report has the same error' do + before do + base_report.add_url(url, single_error) + head_report.add_url(url, single_error + different_error) + end + + it 'returns the existing error' do + expect(existing_errors).to eq(single_error) end end end describe '#new_errors' do - subject { comparer.new_errors } + subject(:new_errors) { comparer.new_errors } context 'when base reports has errors and head has more errors' do before do - base_reports.add_url(url, single_error) - head_reports.add_url(url, single_error + different_error) + base_report.add_url(url, single_error) + head_report.add_url(url, single_error + different_error) end it 'returns new errors between base and head reports' do - expect(subject.size).to eq(1) - expect(subject.first["code"]).to eq("WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail") + expect(new_errors.size).to eq(1) + expect(new_errors.first["code"]).to eq("WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail") end end context 'when base reports has an error and head has no errors' do before do - base_reports.add_url(url, single_error) - head_reports.add_url(url, []) + base_report.add_url(url, single_error) + head_report.add_url(url, []) end it 'returns an empty array' do - expect(subject).to be_empty + expect(new_errors).to be_empty end end context 'when base reports does not have errors and head has errors' do before do - head_reports.add_url(url, single_error) + head_report.add_url(url, single_error) end it 'returns the new error' do - expect(subject.size).to eq(1) - expect(subject.first["code"]).to eq("WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent") + expect(new_errors.size).to eq(1) + expect(new_errors.first["code"]).to eq("WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent") end end end describe '#resolved_errors' do - subject { comparer.resolved_errors } + subject(:resolved_errors) { comparer.resolved_errors } context 'when base report has errors and head has more errors' do before do - base_reports.add_url(url, single_error) - head_reports.add_url(url, single_error + different_error) + base_report.add_url(url, single_error) + head_report.add_url(url, single_error + different_error) end it 'returns an empty array' do - expect(subject).to be_empty + expect(resolved_errors).to be_empty end end context 'when base reports has errors and head has a different error' do before do - base_reports.add_url(url, single_error) - head_reports.add_url(url, different_error) + base_report.add_url(url, single_error) + head_report.add_url(url, different_error) end it 'returns the resolved errors' do - expect(subject.size).to eq(1) - expect(subject.first["code"]).to eq("WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent") + expect(resolved_errors.size).to eq(1) + expect(resolved_errors.first["code"]).to eq("WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent") end end context 'when base reports does not have errors and head has errors' do before do - head_reports.add_url(url, single_error) + head_report.add_url(url, single_error) end it 'returns an empty array' do - expect(subject).to be_empty + expect(resolved_errors).to be_empty end end end diff --git a/spec/lib/gitlab/ci/reports/codequality_reports_comparer_spec.rb b/spec/lib/gitlab/ci/reports/codequality_reports_comparer_spec.rb new file mode 100644 index 00000000000..7053d54381b --- /dev/null +++ b/spec/lib/gitlab/ci/reports/codequality_reports_comparer_spec.rb @@ -0,0 +1,308 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Reports::CodequalityReportsComparer do + let(:comparer) { described_class.new(base_report, head_report) } + let(:base_report) { Gitlab::Ci::Reports::CodequalityReports.new } + let(:head_report) { Gitlab::Ci::Reports::CodequalityReports.new } + let(:degradation_1) do + { + "categories": [ + "Complexity" + ], + "check_name": "argument_count", + "content": { + "body": "" + }, + "description": "Method `new_array` has 12 arguments (exceeds 4 allowed). Consider refactoring.", + "fingerprint": "15cdb5c53afd42bc22f8ca366a08d547", + "location": { + "path": "foo.rb", + "lines": { + "begin": 10, + "end": 10 + } + }, + "other_locations": [], + "remediation_points": 900000, + "severity": "major", + "type": "issue", + "engine_name": "structure" + }.with_indifferent_access + end + + let(:degradation_2) do + { + "type": "Issue", + "check_name": "Rubocop/Metrics/ParameterLists", + "description": "Avoid parameter lists longer than 5 parameters. [12/5]", + "categories": [ + "Complexity" + ], + "remediation_points": 550000, + "location": { + "path": "foo.rb", + "positions": { + "begin": { + "column": 14, + "line": 10 + }, + "end": { + "column": 39, + "line": 10 + } + } + }, + "content": { + "body": "This cop checks for methods with too many parameters.\nThe maximum number of parameters is configurable.\nKeyword arguments can optionally be excluded from the total count." + }, + "engine_name": "rubocop", + "fingerprint": "ab5f8b935886b942d621399f5a2ca16e", + "severity": "minor" + }.with_indifferent_access + end + + describe '#status' do + subject(:report_status) { comparer.status } + + context 'when head report has an error' do + before do + head_report.add_degradation(degradation_1) + end + + it 'returns status failed' do + expect(report_status).to eq(described_class::STATUS_FAILED) + end + end + + context 'when head report does not have errors' do + it 'returns status success' do + expect(report_status).to eq(described_class::STATUS_SUCCESS) + end + end + end + + describe '#errors_count' do + subject(:errors_count) { comparer.errors_count } + + context 'when head report has an error' do + before do + head_report.add_degradation(degradation_1) + end + + it 'returns the number of new errors' do + expect(errors_count).to eq(1) + end + end + + context 'when head report does not have an error' do + it 'returns zero' do + expect(errors_count).to be_zero + end + end + end + + describe '#resolved_count' do + subject(:resolved_count) { comparer.resolved_count } + + context 'when base report has an error and head has a different error' do + before do + base_report.add_degradation(degradation_1) + head_report.add_degradation(degradation_2) + end + + it 'counts the base report error as resolved' do + expect(resolved_count).to eq(1) + end + end + + context 'when base report has errors head has no errors' do + before do + base_report.add_degradation(degradation_1) + end + + it 'counts the base report errors as resolved' do + expect(resolved_count).to eq(1) + end + end + + context 'when base report has errors and head has the same error' do + before do + base_report.add_degradation(degradation_1) + head_report.add_degradation(degradation_1) + end + + it 'returns zero' do + expect(resolved_count).to eq(0) + end + end + + context 'when base report does not have errors and head has errors' do + before do + head_report.add_degradation(degradation_1) + end + + it 'returns zero' do + expect(resolved_count).to be_zero + end + end + end + + describe '#total_count' do + subject(:total_count) { comparer.total_count } + + context 'when base report has an error' do + before do + base_report.add_degradation(degradation_1) + end + + it 'returns zero' do + expect(total_count).to be_zero + end + end + + context 'when head report has an error' do + before do + head_report.add_degradation(degradation_1) + end + + it 'includes the head report error in the count' do + expect(total_count).to eq(1) + end + end + + context 'when base report has errors and head report has errors' do + before do + base_report.add_degradation(degradation_1) + head_report.add_degradation(degradation_2) + end + + it 'includes errors in the count' do + expect(total_count).to eq(1) + end + end + + context 'when base report has errors and head report has the same error' do + before do + base_report.add_degradation(degradation_1) + head_report.add_degradation(degradation_1) + head_report.add_degradation(degradation_2) + end + + it 'includes errors in the count' do + expect(total_count).to eq(2) + end + end + end + + describe '#existing_errors' do + subject(:existing_errors) { comparer.existing_errors } + + context 'when base report has errors and head has the same error' do + before do + base_report.add_degradation(degradation_1) + head_report.add_degradation(degradation_1) + head_report.add_degradation(degradation_2) + end + + it 'includes the base report errors' do + expect(existing_errors).to contain_exactly(degradation_1) + end + end + + context 'when base report has errors and head has a different error' do + before do + base_report.add_degradation(degradation_1) + head_report.add_degradation(degradation_2) + end + + it 'returns an empty array' do + expect(existing_errors).to be_empty + end + end + + context 'when base report does not have errors and head has errors' do + before do + head_report.add_degradation(degradation_1) + end + + it 'returns an empty array' do + expect(existing_errors).to be_empty + end + end + end + + describe '#new_errors' do + subject(:new_errors) { comparer.new_errors } + + context 'when base report has errors and head has more errors' do + before do + base_report.add_degradation(degradation_1) + head_report.add_degradation(degradation_1) + head_report.add_degradation(degradation_2) + end + + it 'includes errors not found in the base report' do + expect(new_errors).to eq([degradation_2]) + end + end + + context 'when base report has an error and head has no errors' do + before do + base_report.add_degradation(degradation_1) + end + + it 'returns an empty array' do + expect(new_errors).to be_empty + end + end + + context 'when base report does not have errors and head has errors' do + before do + head_report.add_degradation(degradation_1) + end + + it 'returns the head report error' do + expect(new_errors).to eq([degradation_1]) + end + end + end + + describe '#resolved_errors' do + subject(:resolved_errors) { comparer.resolved_errors } + + context 'when base report errors are still found in the head report' do + before do + base_report.add_degradation(degradation_1) + head_report.add_degradation(degradation_1) + head_report.add_degradation(degradation_2) + end + + it 'returns an empty array' do + expect(resolved_errors).to be_empty + end + end + + context 'when base report has errors and head has a different error' do + before do + base_report.add_degradation(degradation_1) + head_report.add_degradation(degradation_2) + end + + it 'returns the base report error' do + expect(resolved_errors).to eq([degradation_1]) + end + end + + context 'when base report does not have errors and head has errors' do + before do + head_report.add_degradation(degradation_1) + end + + it 'returns an empty array' do + expect(resolved_errors).to be_empty + end + end + end +end diff --git a/spec/lib/gitlab/ci/reports/codequality_reports_spec.rb b/spec/lib/gitlab/ci/reports/codequality_reports_spec.rb new file mode 100644 index 00000000000..44e67259369 --- /dev/null +++ b/spec/lib/gitlab/ci/reports/codequality_reports_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Reports::CodequalityReports do + let(:codequality_report) { described_class.new } + let(:degradation_1) do + { + "categories": [ + "Complexity" + ], + "check_name": "argument_count", + "content": { + "body": "" + }, + "description": "Method `new_array` has 12 arguments (exceeds 4 allowed). Consider refactoring.", + "fingerprint": "15cdb5c53afd42bc22f8ca366a08d547", + "location": { + "path": "foo.rb", + "lines": { + "begin": 10, + "end": 10 + } + }, + "other_locations": [], + "remediation_points": 900000, + "severity": "major", + "type": "issue", + "engine_name": "structure" + }.with_indifferent_access + end + + let(:degradation_2) do + { + "type": "Issue", + "check_name": "Rubocop/Metrics/ParameterLists", + "description": "Avoid parameter lists longer than 5 parameters. [12/5]", + "categories": [ + "Complexity" + ], + "remediation_points": 550000, + "location": { + "path": "foo.rb", + "positions": { + "begin": { + "column": 14, + "line": 10 + }, + "end": { + "column": 39, + "line": 10 + } + } + }, + "content": { + "body": "This cop checks for methods with too many parameters.\nThe maximum number of parameters is configurable.\nKeyword arguments can optionally be excluded from the total count." + }, + "engine_name": "rubocop", + "fingerprint": "ab5f8b935886b942d621399f5a2ca16e", + "severity": "minor" + }.with_indifferent_access + end + + it { expect(codequality_report.degradations).to eq({}) } + + describe '#add_degradation' do + context 'when there is a degradation' do + before do + codequality_report.add_degradation(degradation_1) + end + + it 'adds degradation to codequality report' do + expect(codequality_report.degradations.keys).to eq([degradation_1[:fingerprint]]) + expect(codequality_report.degradations.values.size).to eq(1) + end + end + + context 'when a required property is missing in the degradation' do + let(:invalid_degradation) do + { + "type": "Issue", + "check_name": "Rubocop/Metrics/ParameterLists", + "description": "Avoid parameter lists longer than 5 parameters. [12/5]", + "fingerprint": "ab5f8b935886b942d621399aefkaehfiaehf", + "severity": "minor" + }.with_indifferent_access + end + + it 'sets location as an error' do + codequality_report.add_degradation(invalid_degradation) + + expect(codequality_report.error_message).to eq("Invalid degradation format: The property '#/' did not contain a required property of 'location'") + end + end + end + + describe '#set_error_message' do + context 'when there is an error' do + it 'sets errors' do + codequality_report.set_error_message("error") + + expect(codequality_report.error_message).to eq("error") + end + end + end + + describe '#degradations_count' do + subject(:degradations_count) { codequality_report.degradations_count } + + context 'when there are many degradations' do + before do + codequality_report.add_degradation(degradation_1) + codequality_report.add_degradation(degradation_2) + end + + it 'returns the number of degradations' do + expect(degradations_count).to eq(2) + end + end + end + + describe '#all_degradations' do + subject(:all_degradations) { codequality_report.all_degradations } + + context 'when there are many degradations' do + before do + codequality_report.add_degradation(degradation_1) + codequality_report.add_degradation(degradation_2) + end + + it 'returns all degradations' do + expect(all_degradations).to contain_exactly(degradation_1, degradation_2) + end + end + end +end diff --git a/spec/lib/gitlab/ci/reports/reports_comparer_spec.rb b/spec/lib/gitlab/ci/reports/reports_comparer_spec.rb new file mode 100644 index 00000000000..1e5e4766583 --- /dev/null +++ b/spec/lib/gitlab/ci/reports/reports_comparer_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Reports::ReportsComparer do + let(:comparer) { described_class.new(base_report, head_report) } + let(:base_report) { Gitlab::Ci::Reports::CodequalityReports.new } + let(:head_report) { Gitlab::Ci::Reports::CodequalityReports.new } + + describe '#initialize' do + context 'sets getter for the report comparer' do + it 'return base report' do + expect(comparer.base_report).to be_an_instance_of(Gitlab::Ci::Reports::CodequalityReports) + end + + it 'return head report' do + expect(comparer.head_report).to be_an_instance_of(Gitlab::Ci::Reports::CodequalityReports) + end + end + end + + describe '#status' do + subject(:status) { comparer.status } + + it 'returns not implemented error' do + expect { status }.to raise_error(NotImplementedError) + end + + context 'when success? is true' do + before do + allow(comparer).to receive(:success?).and_return(true) + end + + it 'returns status success' do + expect(status).to eq('success') + end + end + + context 'when success? is false' do + before do + allow(comparer).to receive(:success?).and_return(false) + end + + it 'returns status failed' do + expect(status).to eq('failed') + end + end + end + + describe '#success?' do + subject(:success?) { comparer.success? } + + it 'returns not implemented error' do + expect { success? }.to raise_error(NotImplementedError) + end + end + + describe '#existing_errors' do + subject(:existing_errors) { comparer.existing_errors } + + it 'returns not implemented error' do + expect { existing_errors }.to raise_error(NotImplementedError) + end + end + + describe '#resolved_errors' do + subject(:resolved_errors) { comparer.resolved_errors } + + it 'returns not implemented error' do + expect { resolved_errors }.to raise_error(NotImplementedError) + end + end + + describe '#errors_count' do + subject(:errors_count) { comparer.errors_count } + + it 'returns not implemented error' do + expect { errors_count }.to raise_error(NotImplementedError) + end + end + + describe '#resolved_count' do + subject(:resolved_count) { comparer.resolved_count } + + it 'returns not implemented error' do + expect { resolved_count }.to raise_error(NotImplementedError) + end + end + + describe '#total_count' do + subject(:total_count) { comparer.total_count } + + it 'returns not implemented error' do + expect { total_count }.to raise_error(NotImplementedError) + end + end +end diff --git a/spec/lib/gitlab/ci/templates/npm_spec.rb b/spec/lib/gitlab/ci/templates/npm_spec.rb new file mode 100644 index 00000000000..1f8e32ce019 --- /dev/null +++ b/spec/lib/gitlab/ci/templates/npm_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'npm.latest.gitlab-ci.yml' do + subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('npm.latest') } + + describe 'the created pipeline' do + let_it_be(:user) { create(:admin) } + + let(:repo_files) { { 'package.json' => '{}', 'README.md' => '' } } + let(:modified_files) { %w[package.json] } + let(:project) { create(:project, :custom_repo, files: repo_files) } + let(:pipeline_branch) { project.default_branch } + let(:pipeline_tag) { 'v1.2.1' } + let(:pipeline_ref) { pipeline_branch } + let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_ref ) } + let(:pipeline) { service.execute!(:push) } + let(:build_names) { pipeline.builds.pluck(:name) } + + def create_branch(name:) + ::Branches::CreateService.new(project, user).execute(name, project.default_branch) + end + + def create_tag(name:) + ::Tags::CreateService.new(project, user).execute(name, project.default_branch, nil) + end + + before do + stub_ci_pipeline_yaml_file(template.content) + + create_branch(name: pipeline_branch) + create_tag(name: pipeline_tag) + + allow_any_instance_of(Ci::Pipeline).to receive(:modified_paths).and_return(modified_files) + end + + shared_examples 'publish job created' do + it 'creates a pipeline with a single job: publish' do + expect(build_names).to eq(%w[publish]) + end + end + + shared_examples 'no pipeline created' do + it 'does not create a pipeline because the only job (publish) is not created' do + expect { pipeline }.to raise_error(Ci::CreatePipelineService::CreateError, 'No stages / jobs for this pipeline.') + end + end + + context 'on default branch' do + context 'when package.json has been changed' do + it_behaves_like 'publish job created' + end + + context 'when package.json does not exist or has not been changed' do + let(:modified_files) { %w[README.md] } + + it_behaves_like 'no pipeline created' + end + end + + %w[v1.0.0 v2.1.0-alpha].each do |valid_version| + context "when the branch name is #{valid_version}" do + let(:pipeline_branch) { valid_version } + + it_behaves_like 'publish job created' + end + + context "when the tag name is #{valid_version}" do + let(:pipeline_tag) { valid_version } + let(:pipeline_ref) { pipeline_tag } + + it_behaves_like 'publish job created' + end + end + + %w[patch-1 my-feature-branch v1 v1.0 2.1.0].each do |invalid_version| + context "when the branch name is #{invalid_version}" do + let(:pipeline_branch) { invalid_version } + + it_behaves_like 'no pipeline created' + end + + context "when the tag name is #{invalid_version}" do + let(:pipeline_tag) { invalid_version } + let(:pipeline_ref) { pipeline_tag } + + it_behaves_like 'no pipeline created' + end + end + end +end diff --git a/spec/lib/gitlab/ci/trace/checksum_spec.rb b/spec/lib/gitlab/ci/trace/checksum_spec.rb index 794794c3f69..a343d74f755 100644 --- a/spec/lib/gitlab/ci/trace/checksum_spec.rb +++ b/spec/lib/gitlab/ci/trace/checksum_spec.rb @@ -8,8 +8,12 @@ RSpec.describe Gitlab::Ci::Trace::Checksum do subject { described_class.new(build) } context 'when build pending state exists' do + let(:trace_details) do + { trace_checksum: 'crc32:d4777540', trace_bytesize: 262161 } + end + before do - create(:ci_build_pending_state, build: build, trace_checksum: 'crc32:d4777540') + create(:ci_build_pending_state, build: build, **trace_details) end context 'when matching persisted trace chunks exist' do @@ -22,6 +26,7 @@ RSpec.describe Gitlab::Ci::Trace::Checksum do it 'calculates combined trace chunks CRC32 correctly' do expect(subject.chunks_crc32).to eq 3564598592 expect(subject).to be_valid + expect(subject).not_to be_corrupted end end @@ -32,8 +37,9 @@ RSpec.describe Gitlab::Ci::Trace::Checksum do create_chunk(index: 2, data: 'ccccccccccccccccc') end - it 'makes trace checksum invalid' do + it 'makes trace checksum invalid but not corrupted' do expect(subject).not_to be_valid + expect(subject).not_to be_corrupted end end @@ -43,8 +49,9 @@ RSpec.describe Gitlab::Ci::Trace::Checksum do create_chunk(index: 2, data: 'ccccccccccccccccc') end - it 'makes trace checksum invalid' do + it 'makes trace checksum invalid and corrupted' do expect(subject).not_to be_valid + expect(subject).to be_corrupted end end @@ -55,8 +62,9 @@ RSpec.describe Gitlab::Ci::Trace::Checksum do create_chunk(index: 2, data: 'ccccccccccccccccc') end - it 'makes trace checksum invalid' do + it 'makes trace checksum invalid but not corrupted' do expect(subject).not_to be_valid + expect(subject).not_to be_corrupted end end @@ -99,6 +107,14 @@ RSpec.describe Gitlab::Ci::Trace::Checksum do it 'returns nil' do expect(subject.last_chunk).to be_nil end + + it 'is not a valid trace' do + expect(subject).not_to be_valid + end + + it 'is not a corrupted trace' do + expect(subject).not_to be_corrupted + end end context 'when there are multiple chunks' do @@ -110,6 +126,26 @@ RSpec.describe Gitlab::Ci::Trace::Checksum do it 'returns chunk with the highest index' do expect(subject.last_chunk.chunk_index).to eq 1 end + + it 'is not a valid trace' do + expect(subject).not_to be_valid + end + + it 'is not a corrupted trace' do + expect(subject).not_to be_corrupted + end + end + end + + describe '#trace_size' do + before do + create_chunk(index: 0, data: 'a' * 128.kilobytes) + create_chunk(index: 1, data: 'b' * 128.kilobytes) + create_chunk(index: 2, data: 'abcdefg-ΓΌ') + end + + it 'returns total trace size in bytes' do + expect(subject.trace_size).to eq 262154 end end diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index fb6395e888a..5ad1b3dd241 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -231,6 +231,23 @@ module Gitlab expect(subject[:allow_failure]).to be true end end + + context 'when allow_failure has exit_codes' do + let(:config) do + YAML.dump(rspec: { script: 'rspec', + when: 'manual', + allow_failure: { exit_codes: 1 } }) + end + + it 'is not allowed to fail' do + expect(subject[:allow_failure]).to be false + end + + it 'saves allow_failure_criteria into options' do + expect(subject[:options]).to match( + a_hash_including(allow_failure_criteria: { exit_codes: [1] })) + end + end end context 'when job is not a manual action' do @@ -254,6 +271,22 @@ module Gitlab expect(subject[:allow_failure]).to be false end end + + context 'when allow_failure is dynamically specified' do + let(:config) do + YAML.dump(rspec: { script: 'rspec', + allow_failure: { exit_codes: 1 } }) + end + + it 'is not allowed to fail' do + expect(subject[:allow_failure]).to be false + end + + it 'saves allow_failure_criteria into options' do + expect(subject[:options]).to match( + a_hash_including(allow_failure_criteria: { exit_codes: [1] })) + end + end end end @@ -2111,6 +2144,71 @@ module Gitlab end end + describe 'cross pipeline needs' do + context 'when configuration is valid' do + let(:config) do + <<~YAML + rspec: + stage: test + script: rspec + needs: + - pipeline: $THE_PIPELINE_ID + job: dependency-job + YAML + end + + it 'returns a valid configuration and sets artifacts: true by default' do + expect(subject).to be_valid + + rspec = subject.build_attributes(:rspec) + expect(rspec.dig(:options, :cross_dependencies)).to eq( + [{ pipeline: '$THE_PIPELINE_ID', job: 'dependency-job', artifacts: true }] + ) + end + + context 'when pipeline ID is hard-coded' do + let(:config) do + <<~YAML + rspec: + stage: test + script: rspec + needs: + - pipeline: "123" + job: dependency-job + YAML + end + + it 'returns a valid configuration and sets artifacts: true by default' do + expect(subject).to be_valid + + rspec = subject.build_attributes(:rspec) + expect(rspec.dig(:options, :cross_dependencies)).to eq( + [{ pipeline: '123', job: 'dependency-job', artifacts: true }] + ) + end + end + end + + context 'when configuration is not valid' do + let(:config) do + <<~YAML + rspec: + stage: test + script: rspec + needs: + - pipeline: $THE_PIPELINE_ID + job: dependency-job + something: else + YAML + end + + it 'returns an error' do + expect(subject).not_to be_valid + expect(subject.errors).to include(/:need config contains unknown keys: something/) + end + end + end + describe "Hidden jobs" do let(:config_processor) { Gitlab::Ci::YamlProcessor.new(config).execute } @@ -2429,7 +2527,13 @@ module Gitlab context 'returns errors if job allow_failure parameter is not an boolean' do let(:config) { YAML.dump({ rspec: { script: "test", allow_failure: "string" } }) } - it_behaves_like 'returns errors', 'jobs:rspec allow failure should be a boolean value' + it_behaves_like 'returns errors', 'jobs:rspec allow failure should be a hash or a boolean value' + end + + context 'returns errors if job exit_code parameter from allow_failure is not an integer' do + let(:config) { YAML.dump({ rspec: { script: "test", allow_failure: { exit_codes: 'string' } } }) } + + it_behaves_like 'returns errors', 'jobs:rspec:allow_failure exit codes should be an array of integers or an integer' end context 'returns errors if job stage is not a string' do |