diff options
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue | 2 | ||||
-rw-r--r-- | app/assets/stylesheets/utilities.scss | 5 | ||||
-rw-r--r-- | app/helpers/packages_helper.rb | 20 | ||||
-rw-r--r-- | app/helpers/page_layout_helper.rb | 2 | ||||
-rw-r--r-- | app/models/ci/runner.rb | 22 | ||||
-rw-r--r-- | app/models/project.rb | 2 | ||||
-rw-r--r-- | app/views/projects/ci/pipeline_editor/show.html.haml | 1 | ||||
-rw-r--r-- | doc/ci/pipelines/cicd_minutes.md | 2 | ||||
-rw-r--r-- | doc/development/documentation/styleguide/word_list.md | 4 | ||||
-rw-r--r-- | qa/qa/page/trials/new.rb | 17 | ||||
-rw-r--r-- | qa/qa/resource/base.rb | 6 | ||||
-rw-r--r-- | qa/qa/support/page_error_checker.rb | 62 | ||||
-rw-r--r-- | qa/qa/support/wait_for_requests.rb | 7 | ||||
-rw-r--r-- | qa/spec/page/logging_spec.rb | 1 | ||||
-rw-r--r-- | qa/spec/resource/base_spec.rb | 13 | ||||
-rw-r--r-- | qa/spec/support/page_error_checker_spec.rb | 217 | ||||
-rw-r--r-- | qa/spec/support/wait_for_requests_spec.rb | 16 | ||||
-rw-r--r-- | spec/helpers/packages_helper_spec.rb | 41 |
19 files changed, 351 insertions, 90 deletions
diff --git a/.gitignore b/.gitignore index 0a7808601ea..03f77ed89e5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ eslint-report.html /.gitlab_shell_secret .idea +.nova /.vscode/* /.rbenv-version .rbx/ diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue index 8e8f31a4acc..96680080f0c 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue @@ -90,7 +90,7 @@ export default { </script> <template> - <div class="gl-pr-9 gl-transition-medium gl-w-full"> + <div class="gl-pr-10 gl-transition-medium gl-w-full"> <gl-modal v-if="showSwitchBranchModal" visible diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 6b1ec0666f8..07ef80ef7e1 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -311,3 +311,8 @@ $gl-line-height-42: px-to-rem(42px); padding-right: $gl-spacing-scale-5; } } + +// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2600 +.gl-pr-10 { + padding-right: $gl-spacing-scale-10; +} diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb index cf97fdf73e3..402a363349f 100644 --- a/app/helpers/packages_helper.rb +++ b/app/helpers/packages_helper.rb @@ -55,24 +55,4 @@ module PackagesHelper project.container_expiration_policy.nil? && project.container_repositories.exists? end - - def package_details_data(project, package) - { - package_id: package.id, - can_delete: can?(current_user, :destroy_package, project).to_s, - svg_path: image_path('illustrations/no-packages.svg'), - npm_path: package_registry_instance_url(:npm), - npm_project_path: package_registry_project_url(project.id, :npm), - maven_path: package_registry_project_url(project.id, :maven), - conan_path: package_registry_project_url(project.id, :conan), - nuget_path: nuget_package_registry_url(project.id), - pypi_path: pypi_registry_url(project.id), - pypi_setup_path: package_registry_project_url(project.id, :pypi), - composer_path: composer_registry_url(project&.group&.id), - project_name: project.name, - project_list_url: project_packages_path(project), - group_list_url: project.group ? group_packages_path(project.group) : '', - composer_config_repository_name: composer_config_repository_name(project.group&.id) - } - end end diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb index 2729951d685..fb74a52fcda 100644 --- a/app/helpers/page_layout_helper.rb +++ b/app/helpers/page_layout_helper.rb @@ -125,7 +125,7 @@ module PageLayoutHelper end def fluid_layout - current_user && current_user.layout == "fluid" + @force_fluid_layout == true || (current_user && current_user.layout == "fluid") end def blank_container(enabled = false) diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 1c469377231..8b2d987ed90 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -255,18 +255,16 @@ module Ci Arel.sql("(#{arel_tag_names_array.to_sql})") ] - ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/339621') do - group(*unique_params).pluck('array_agg(ci_runners.id)', *unique_params).map do |values| - Gitlab::Ci::Matching::RunnerMatcher.new({ - runner_ids: values[0], - runner_type: values[1], - public_projects_minutes_cost_factor: values[2], - private_projects_minutes_cost_factor: values[3], - run_untagged: values[4], - access_level: values[5], - tag_list: values[6] - }) - end + group(*unique_params).pluck('array_agg(ci_runners.id)', *unique_params).map do |values| + Gitlab::Ci::Matching::RunnerMatcher.new({ + runner_ids: values[0], + runner_type: values[1], + public_projects_minutes_cost_factor: values[2], + private_projects_minutes_cost_factor: values[3], + run_untagged: values[4], + access_level: values[5], + tag_list: values[6] + }) end end diff --git a/app/models/project.rb b/app/models/project.rb index a553246ed79..5aa4b4d9968 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -454,7 +454,7 @@ class Project < ApplicationRecord delegate :job_token_scope_enabled, :job_token_scope_enabled=, to: :ci_cd_settings, prefix: :ci, allow_nil: true delegate :keep_latest_artifact, :keep_latest_artifact=, to: :ci_cd_settings, allow_nil: true delegate :restrict_user_defined_variables, :restrict_user_defined_variables=, to: :ci_cd_settings, allow_nil: true - delegate :actual_limits, :actual_plan_name, to: :namespace, allow_nil: true + delegate :actual_limits, :actual_plan_name, :actual_plan, to: :namespace, allow_nil: true delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?, :allow_merge_on_skipped_pipeline=, :has_confluence?, :has_shimo?, to: :project_setting diff --git a/app/views/projects/ci/pipeline_editor/show.html.haml b/app/views/projects/ci/pipeline_editor/show.html.haml index ce6f7553ab4..c4757ea9c26 100644 --- a/app/views/projects/ci/pipeline_editor/show.html.haml +++ b/app/views/projects/ci/pipeline_editor/show.html.haml @@ -1,3 +1,4 @@ +- @force_fluid_layout = true - add_page_specific_style 'page_bundles/pipelines' - page_title s_('Pipelines|Pipeline Editor') diff --git a/doc/ci/pipelines/cicd_minutes.md b/doc/ci/pipelines/cicd_minutes.md index 2e9aca572c3..e0fb5b45986 100644 --- a/doc/ci/pipelines/cicd_minutes.md +++ b/doc/ci/pipelines/cicd_minutes.md @@ -180,6 +180,8 @@ The cost factor for a job running on a shared runner is: - `0.008` for public projects on GitLab SaaS, if [created 2021-07-17 or later](https://gitlab.com/gitlab-org/gitlab/-/issues/332708). (For every 125 minutes of job time, you accrue 1 CD/CD minute.) +- `0.008` for projects members of GitLab [Open Source program](../../subscriptions/index.md#gitlab-for-open-source). + (For every 125 minutes of job time, you accrue 1 CD/CD minute.) - `0` for public projects on GitLab self-managed instances, and for GitLab SaaS public projects created before 2021-07-17. - `1` for internal and private projects. diff --git a/doc/development/documentation/styleguide/word_list.md b/doc/development/documentation/styleguide/word_list.md index 6d069dad8af..a8c5bf6b579 100644 --- a/doc/development/documentation/styleguide/word_list.md +++ b/doc/development/documentation/styleguide/word_list.md @@ -627,6 +627,10 @@ When writing about the Owner role: Do not use **Owner permissions**. A user who is assigned the Owner role has a set of associated permissions. +## Package Registry + +Use title case for the GitLab Package Registry. + ## permissions Do not use [**roles**](#roles) and **permissions** interchangeably. Each user is assigned a role. Each role includes a set of permissions. diff --git a/qa/qa/page/trials/new.rb b/qa/qa/page/trials/new.rb index 11907e0575a..6e9d7fce688 100644 --- a/qa/qa/page/trials/new.rb +++ b/qa/qa/page/trials/new.rb @@ -6,16 +6,13 @@ module QA class New < Chemlab::Page path '/-/trials/new' - # TODO: Supplant with data-qa-selectors - text_field :first_name, id: 'first_name' - text_field :last_name, id: 'last_name' - text_field :company_name, id: 'company_name' - select :number_of_employees, id: 'company_size' - text_field :telephone_number, id: 'phone_number' - - select :country, id: 'country_select' - - button :continue, value: 'Continue' + text_field :first_name + text_field :last_name + text_field :company_name + select :number_of_employees + text_field :telephone_number + select :country + button :continue end end end diff --git a/qa/qa/resource/base.rb b/qa/qa/resource/base.rb index b2b7e3b719f..523924aa69a 100644 --- a/qa/qa/resource/base.rb +++ b/qa/qa/resource/base.rb @@ -166,11 +166,11 @@ module QA raise NotImplementedError end - def visit! + def visit!(skip_resp_code_check: false) Runtime::Logger.debug(%(Visiting #{self.class.name} at "#{web_url}")) # Just in case an async action is not yet complete - Support::WaitForRequests.wait_for_requests + Support::WaitForRequests.wait_for_requests(skip_resp_code_check: skip_resp_code_check) Support::Retrier.retry_until do visit(web_url) @@ -178,7 +178,7 @@ module QA end # Wait until the new page is ready for us to interact with it - Support::WaitForRequests.wait_for_requests + Support::WaitForRequests.wait_for_requests(skip_resp_code_check: skip_resp_code_check) end def populate(*attribute_names) diff --git a/qa/qa/support/page_error_checker.rb b/qa/qa/support/page_error_checker.rb new file mode 100644 index 00000000000..5d16245b4cd --- /dev/null +++ b/qa/qa/support/page_error_checker.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module QA + module Support + class PageErrorChecker + class << self + def report!(page, error_code) + report = if QA::Runtime::Env.browser == :chrome + return_chrome_errors(page, error_code) + else + status_code_report(error_code) + end + + raise "#{report}\n\n"\ + "Path: #{page.current_path}" + end + + def return_chrome_errors(page, error_code) + severe_errors = logs(page).select { |log| log.level == 'SEVERE' } + if severe_errors.none? + status_code_report(error_code) + else + "There #{severe_errors.count == 1 ? 'was' : 'were'} #{severe_errors.count} "\ + "SEVERE level error#{severe_errors.count == 1 ? '' : 's'}:\n\n#{error_report_for(severe_errors)}" + end + end + + def status_code_report(error_code) + "Status code #{error_code} found" + end + + def check_page_for_error_code(page) + error_code = 0 + # Test for 404 img alt + error_code = 404 if Nokogiri::HTML.parse(page.html).xpath("//img").map { |t| t[:alt] }.first.eql?('404') + + # 500 error page in header surrounded by newlines, try to match + five_hundred_test = Nokogiri::HTML.parse(page.html).xpath("//h1").map.first + unless five_hundred_test.nil? + error_code = 500 if five_hundred_test.text.include?('500') + end + # GDK shows backtrace rather than error page + error_code = 500 if Nokogiri::HTML.parse(page.html).xpath("//body//section").map { |t| t[:class] }.first.eql?('backtrace') + + unless error_code == 0 + report!(page, error_code) + end + end + + def error_report_for(logs) + logs + .map(&:message) + .map { |message| message.gsub('\\n', "\n") } + end + + def logs(page) + page.driver.browser.manage.logs.get(:browser) + end + end + end + end +end diff --git a/qa/qa/support/wait_for_requests.rb b/qa/qa/support/wait_for_requests.rb index 5109f51d4d7..16af4bae521 100644 --- a/qa/qa/support/wait_for_requests.rb +++ b/qa/qa/support/wait_for_requests.rb @@ -7,7 +7,12 @@ module QA DEFAULT_MAX_WAIT_TIME = 60 - def wait_for_requests(skip_finished_loading_check: false) + def wait_for_requests(skip_finished_loading_check: false, skip_resp_code_check: false) + # We have tests that use 404 pages, allow them to skip this check + unless skip_resp_code_check + QA::Support::PageErrorChecker.check_page_for_error_code(Capybara.page) + end + Waiter.wait_until(log: false) do finished_all_ajax_requests? && (!skip_finished_loading_check ? finished_loading?(wait: 1) : true) end diff --git a/qa/spec/page/logging_spec.rb b/qa/spec/page/logging_spec.rb index 7c521f60b84..98326ecd343 100644 --- a/qa/spec/page/logging_spec.rb +++ b/qa/spec/page/logging_spec.rb @@ -14,6 +14,7 @@ RSpec.describe QA::Support::Page::Logging do allow(page).to receive(:find).and_return(page) allow(page).to receive(:current_url).and_return('http://current-url') allow(page).to receive(:has_css?).with(any_args).and_return(true) + allow(QA::Support::PageErrorChecker).to receive(:check_page_for_error_code).and_return(0) end subject do diff --git a/qa/spec/resource/base_spec.rb b/qa/spec/resource/base_spec.rb index 2a26a479436..2dd25f983bf 100644 --- a/qa/spec/resource/base_spec.rb +++ b/qa/spec/resource/base_spec.rb @@ -277,17 +277,30 @@ RSpec.describe QA::Resource::Base do describe '#visit!' do include_context 'with simple resource' + let(:wait_for_requests_class) { QA::Support::WaitForRequests } + before do allow(resource).to receive(:visit) end it 'calls #visit with the underlying #web_url' do allow(resource).to receive(:current_url).and_return(subject.current_url) + expect(wait_for_requests_class).to receive(:wait_for_requests).with({ skip_resp_code_check: false }).twice resource.web_url = subject.current_url resource.visit! expect(resource).to have_received(:visit).with(subject.current_url) end + + it 'calls #visit with the underlying #web_url with skip_resp_code_check specified as true' do + allow(resource).to receive(:current_url).and_return(subject.current_url) + expect(wait_for_requests_class).to receive(:wait_for_requests).with({ skip_resp_code_check: true }).twice + + resource.web_url = subject.current_url + resource.visit!(skip_resp_code_check: true) + + expect(resource).to have_received(:visit).with(subject.current_url) + end end end diff --git a/qa/spec/support/page_error_checker_spec.rb b/qa/spec/support/page_error_checker_spec.rb new file mode 100644 index 00000000000..764b6110e08 --- /dev/null +++ b/qa/spec/support/page_error_checker_spec.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +RSpec.describe QA::Support::PageErrorChecker do + let(:test_path) { '/test/path' } + + let(:page) { double(Capybara.page) } + + describe '.report!' do + context 'reports errors' do + let(:expected_chrome_error) do + "chrome errors\n\n"\ + "Path: #{test_path}" + end + + let(:expected_basic_error) do + "foo status\n\n"\ + "Path: #{test_path}" + end + + it 'reports error message on chrome browser' do + allow(QA::Support::PageErrorChecker).to receive(:return_chrome_errors).and_return('chrome errors') + allow(page).to receive(:current_path).and_return(test_path) + allow(QA::Runtime::Env).to receive(:browser).and_return(:chrome) + + expect { QA::Support::PageErrorChecker.report!(page, 500) }.to raise_error(RuntimeError, expected_chrome_error) + end + + it 'reports basic message on non-chrome browser' do + allow(QA::Support::PageErrorChecker).to receive(:status_code_report).and_return('foo status') + allow(page).to receive(:current_path).and_return(test_path) + allow(QA::Runtime::Env).to receive(:browser).and_return(:firefox) + + expect { QA::Support::PageErrorChecker.report!(page, 500) }.to raise_error(RuntimeError, expected_basic_error) + end + end + end + + describe '.return_chrome_errors' do + context 'returns error message' do + before do + single_log = Class.new do + def level + 'SEVERE' + end + end + stub_const('SingleLog', single_log) + one_error_mocked_logs = Class.new do + def self.select + [SingleLog] + end + end + stub_const('OneErrorMockedLogs', one_error_mocked_logs) + three_errors_mocked_logs = Class.new do + def self.select + [SingleLog, SingleLog, SingleLog] + end + end + stub_const('ThreeErrorsMockedLogs', three_errors_mocked_logs) + no_error_mocked_logs = Class.new do + def self.select + [] + end + end + stub_const('NoErrorMockedLogs', no_error_mocked_logs) + end + + let(:expected_single_error) do + "There was 1 SEVERE level error:\n\n"\ + "bar foo" + end + + let(:expected_multiple_error) do + "There were 3 SEVERE level errors:\n\n"\ + "bar foo\n"\ + "foo\n"\ + "bar" + end + + it 'returns status code report on no severe errors found' do + allow(QA::Support::PageErrorChecker).to receive(:logs).with(page).and_return(NoErrorMockedLogs) + allow(QA::Support::PageErrorChecker).to receive(:status_code_report).with('123').and_return('Test Status Code return 123') + + expect(QA::Support::PageErrorChecker.return_chrome_errors(page, '123')).to eq('Test Status Code return 123') + end + + it 'returns report on 1 severe error found' do + allow(QA::Support::PageErrorChecker).to receive(:error_report_for).with([SingleLog]).and_return('bar foo') + allow(QA::Support::PageErrorChecker).to receive(:logs).with(page).and_return(OneErrorMockedLogs) + allow(page).to receive(:current_path).and_return(test_path) + + expect(QA::Support::PageErrorChecker.return_chrome_errors(page, '123')).to eq(expected_single_error) + end + + it 'returns report on multiple severe errors found' do + allow(QA::Support::PageErrorChecker).to receive(:error_report_for) + .with([SingleLog, SingleLog, SingleLog]).and_return("bar foo\nfoo\nbar") + allow(QA::Support::PageErrorChecker).to receive(:logs).with(page).and_return(ThreeErrorsMockedLogs) + allow(page).to receive(:current_path).and_return(test_path) + + expect(QA::Support::PageErrorChecker.return_chrome_errors(page, '123')).to eq(expected_multiple_error) + end + end + end + + describe '.check_page_for_error_code' do + require 'nokogiri' + before do + nokogiri_parse = Class.new do + def self.parse(str) + Nokogiri::HTML.parse(str) + end + end + stub_const('NokogiriParse', nokogiri_parse) + end + let(:error_404_str) do + "<div class=\"error\">"\ + "<img src=\"404.png\" alt=\"404\" />"\ + "</div>" + end + + let(:error_500_str) { "<h1> 500 </h1>"} + let(:backtrace_str) {"<body><section class=\"backtrace\">foo</section></body>"} + let(:no_error_str) {"<body>no 404 or 500 or backtrace</body>"} + + it 'calls report with 404 if 404 found' do + allow(page).to receive(:html).and_return(error_404_str) + allow(Nokogiri::HTML).to receive(:parse).with(error_404_str).and_return(NokogiriParse.parse(error_404_str)) + + expect(QA::Support::PageErrorChecker).to receive(:report!).with(page, 404) + QA::Support::PageErrorChecker.check_page_for_error_code(page) + end + it 'calls report with 500 if 500 found' do + allow(page).to receive(:html).and_return(error_500_str) + allow(Nokogiri::HTML).to receive(:parse).with(error_500_str).and_return(NokogiriParse.parse(error_500_str)) + + expect(QA::Support::PageErrorChecker).to receive(:report!).with(page, 500) + QA::Support::PageErrorChecker.check_page_for_error_code(page) + end + it 'calls report with 500 if GDK backtrace found' do + allow(page).to receive(:html).and_return(backtrace_str) + allow(Nokogiri::HTML).to receive(:parse).with(backtrace_str).and_return(NokogiriParse.parse(backtrace_str)) + + expect(QA::Support::PageErrorChecker).to receive(:report!).with(page, 500) + QA::Support::PageErrorChecker.check_page_for_error_code(page) + end + it 'does not call report if no 404, 500 or backtrace found' do + allow(page).to receive(:html).and_return(no_error_str) + allow(Nokogiri::HTML).to receive(:parse).with(no_error_str).and_return(NokogiriParse.parse(no_error_str)) + + expect(QA::Support::PageErrorChecker).not_to receive(:report!) + QA::Support::PageErrorChecker.check_page_for_error_code(page) + end + end + + describe '.error_report_for' do + before do + logs_class_one = Class.new do + def self.message + 'foo\\n' + end + end + stub_const('LogOne', logs_class_one) + logs_class_two = Class.new do + def self.message + 'bar' + end + end + stub_const('LogTwo', logs_class_two) + end + + it 'returns error report array of log messages' do + expect(QA::Support::PageErrorChecker.error_report_for([LogOne, LogTwo])) + .to eq(%W(foo\n bar)) + end + end + + describe '.logs' do + before do + logs_class = Class.new do + def self.get(level) + "logs at #{level} level" + end + end + stub_const('Logs', logs_class) + manage_class = Class.new do + def self.logs + Logs + end + end + stub_const('Manage', manage_class) + browser_class = Class.new do + def self.manage + Manage + end + end + stub_const('Browser', browser_class) + driver_class = Class.new do + def self.browser + Browser + end + end + stub_const('Driver', driver_class) + end + + it 'gets driver browser logs' do + allow(page).to receive(:driver).and_return(Driver) + + expect(QA::Support::PageErrorChecker.logs(page)).to eq('logs at browser level') + end + end + + describe '.status_code_report' do + it 'returns a string message containing the status code' do + expect(QA::Support::PageErrorChecker.status_code_report(1234)).to eq('Status code 1234 found') + end + end +end diff --git a/qa/spec/support/wait_for_requests_spec.rb b/qa/spec/support/wait_for_requests_spec.rb index 47c35addd9f..2492820b67f 100644 --- a/qa/spec/support/wait_for_requests_spec.rb +++ b/qa/spec/support/wait_for_requests_spec.rb @@ -22,5 +22,21 @@ RSpec.describe QA::Support::WaitForRequests do subject.wait_for_requests(skip_finished_loading_check: true) end end + + context 'when skip_resp_code_check is defaulted to false' do + it 'call report' do + allow(QA::Support::PageErrorChecker).to receive(:check_page_for_error_code).with(Capybara.page) + + subject.wait_for_requests + end + end + + context 'when skip_resp_code_check is true' do + it 'does not parse for an error code' do + expect(QA::Support::PageErrorChecker).not_to receive(:check_page_for_error_code) + + subject.wait_for_requests(skip_resp_code_check: true) + end + end end end diff --git a/spec/helpers/packages_helper_spec.rb b/spec/helpers/packages_helper_spec.rb index 06c6cccd488..8b3c8411fbd 100644 --- a/spec/helpers/packages_helper_spec.rb +++ b/spec/helpers/packages_helper_spec.rb @@ -219,45 +219,4 @@ RSpec.describe PackagesHelper do it { is_expected.to eq(expected_result) } end end - - describe '#package_details_data' do - let_it_be(:package) { create(:package) } - - let(:expected_result) do - { - package_id: package.id, - can_delete: 'true', - project_name: project.name, - group_list_url: '' - } - end - - before do - allow(helper).to receive(:current_user) { project.owner } - allow(helper).to receive(:can?) { true } - end - - context 'in a project without a group' do - it 'populates presenter data' do - result = helper.package_details_data(project, package) - - expect(result).to match(hash_including(expected_result)) - end - end - - context 'in a project with a group' do - let_it_be(:group) { create(:group) } - let_it_be(:project_with_group) { create(:project, group: group) } - - it 'populates presenter data' do - result = helper.package_details_data(project_with_group, package) - expected = expected_result.merge({ - group_list_url: group_packages_path(project_with_group.group), - project_name: project_with_group.name - }) - - expect(result).to match(hash_including(expected)) - end - end - end end |