diff options
author | Clement Ho <ClemMakesApps@gmail.com> | 2018-05-08 10:49:30 -0500 |
---|---|---|
committer | Clement Ho <ClemMakesApps@gmail.com> | 2018-05-08 10:49:30 -0500 |
commit | 5955caed321e242bfebe52a4b47346a01a50e4f6 (patch) | |
tree | d9710c0732ce21801b4a79a281bec0bd39582a95 /spec | |
parent | f9e2b4730f58ba630344c9554eb907ab003abbd5 (diff) | |
parent | 533593e95cd3a922a2ec2ea43b345862361dfd67 (diff) | |
download | gitlab-ce-5955caed321e242bfebe52a4b47346a01a50e4f6.tar.gz |
Merge branch 'master' into bootstrap4
Diffstat (limited to 'spec')
230 files changed, 8656 insertions, 2482 deletions
diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index fe95d1ef9cd..f0caac40afd 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe ApplicationController do + include TermsHelper + let(:user) { create(:user) } describe '#check_password_expiration' do @@ -406,4 +408,65 @@ describe ApplicationController do end end end + + context 'terms' do + controller(described_class) do + def index + render text: 'authenticated' + end + end + + before do + stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') + sign_in user + end + + it 'does not query more when terms are enforced' do + control = ActiveRecord::QueryRecorder.new { get :index } + + enforce_terms + + expect { get :index }.not_to exceed_query_limit(control) + end + + context 'when terms are enforced' do + before do + enforce_terms + end + + it 'redirects if the user did not accept the terms' do + get :index + + expect(response).to have_gitlab_http_status(302) + end + + it 'does not redirect when the user accepted terms' do + accept_terms(user) + + get :index + + expect(response).to have_gitlab_http_status(200) + end + + context 'for sessionless users' do + before do + sign_out user + end + + it 'renders a 403 when the sessionless user did not accept the terms' do + get :index, rss_token: user.rss_token, format: :atom + + expect(response).to have_gitlab_http_status(403) + end + + it 'renders a 200 when the sessionless user accepted the terms' do + accept_terms(user) + + get :index, rss_token: user.rss_token, format: :atom + + expect(response).to have_gitlab_http_status(200) + end + end + end + end end diff --git a/spec/controllers/concerns/continue_params_spec.rb b/spec/controllers/concerns/continue_params_spec.rb new file mode 100644 index 00000000000..e2f683ae393 --- /dev/null +++ b/spec/controllers/concerns/continue_params_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +describe ContinueParams do + let(:controller_class) do + Class.new(ActionController::Base) do + include ContinueParams + + def request + @request ||= Struct.new(:host, :port).new('test.host', 80) + end + end + end + subject(:controller) { controller_class.new } + + def strong_continue_params(params) + ActionController::Parameters.new(continue: params) + end + + it 'cleans up any params that are not allowed' do + allow(controller).to receive(:params) do + strong_continue_params(to: '/hello', + notice: 'world', + notice_now: '!', + something: 'else') + end + + expect(controller.continue_params.keys).to contain_exactly(*%w(to notice notice_now)) + end + + it 'does not allow cross host redirection' do + allow(controller).to receive(:params) do + strong_continue_params(to: '//example.com') + end + + expect(controller.continue_params[:to]).to be_nil + end + + it 'allows redirecting to a path with querystring' do + allow(controller).to receive(:params) do + strong_continue_params(to: '/hello/world?query=string') + end + + expect(controller.continue_params[:to]).to eq('/hello/world?query=string') + end +end diff --git a/spec/controllers/concerns/internal_redirect_spec.rb b/spec/controllers/concerns/internal_redirect_spec.rb new file mode 100644 index 00000000000..a0ee13b2352 --- /dev/null +++ b/spec/controllers/concerns/internal_redirect_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' + +describe InternalRedirect do + let(:controller_class) do + Class.new do + include InternalRedirect + + def request + @request ||= Struct.new(:host, :port).new('test.host', 80) + end + end + end + subject(:controller) { controller_class.new } + + describe '#safe_redirect_path' do + it 'is `nil` for invalid uris' do + expect(controller.safe_redirect_path('Hello world')).to be_nil + end + + it 'is `nil` for paths trying to include a host' do + expect(controller.safe_redirect_path('//example.com/hello/world')).to be_nil + end + + it 'returns the path if it is valid' do + expect(controller.safe_redirect_path('/hello/world')).to eq('/hello/world') + end + + it 'returns the path with querystring if it is valid' do + expect(controller.safe_redirect_path('/hello/world?hello=world#L123')) + .to eq('/hello/world?hello=world#L123') + end + end + + describe '#safe_redirect_path_for_url' do + it 'is `nil` for invalid urls' do + expect(controller.safe_redirect_path_for_url('Hello world')).to be_nil + end + + it 'is `nil` for urls from a with a different host' do + expect(controller.safe_redirect_path_for_url('http://example.com/hello/world')).to be_nil + end + + it 'is `nil` for urls from a with a different port' do + expect(controller.safe_redirect_path_for_url('http://test.host:3000/hello/world')).to be_nil + end + + it 'returns the path if the url is on the same host' do + expect(controller.safe_redirect_path_for_url('http://test.host/hello/world')).to eq('/hello/world') + end + + it 'returns the path including querystring if the url is on the same host' do + expect(controller.safe_redirect_path_for_url('http://test.host/hello/world?hello=world#L123')) + .to eq('/hello/world?hello=world#L123') + end + end + + describe '#host_allowed?' do + it 'allows uris with the same host and port' do + expect(controller.host_allowed?(URI('http://test.host/test'))).to be(true) + end + + it 'rejects uris with other host and port' do + expect(controller.host_allowed?(URI('http://example.com/test'))).to be(false) + end + end +end diff --git a/spec/controllers/groups/runners_controller_spec.rb b/spec/controllers/groups/runners_controller_spec.rb new file mode 100644 index 00000000000..6d31b0ce959 --- /dev/null +++ b/spec/controllers/groups/runners_controller_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' + +describe Groups::RunnersController do + let(:user) { create(:user) } + let(:group) { create(:group) } + let(:runner) { create(:ci_runner) } + + let(:params) do + { + group_id: group, + id: runner + } + end + + before do + sign_in(user) + group.add_master(user) + group.runners << runner + end + + describe '#update' do + it 'updates the runner and ticks the queue' do + new_desc = runner.description.swapcase + + expect do + post :update, params.merge(runner: { description: new_desc } ) + end.to change { runner.ensure_runner_queue_value } + + runner.reload + + expect(response).to have_gitlab_http_status(302) + expect(runner.description).to eq(new_desc) + end + end + + describe '#destroy' do + it 'destroys the runner' do + delete :destroy, params + + expect(response).to have_gitlab_http_status(302) + expect(Ci::Runner.find_by(id: runner.id)).to be_nil + end + end + + describe '#resume' do + it 'marks the runner as active and ticks the queue' do + runner.update(active: false) + + expect do + post :resume, params + end.to change { runner.ensure_runner_queue_value } + + runner.reload + + expect(response).to have_gitlab_http_status(302) + expect(runner.active).to eq(true) + end + end + + describe '#pause' do + it 'marks the runner as inactive and ticks the queue' do + runner.update(active: true) + + expect do + post :pause, params + end.to change { runner.ensure_runner_queue_value } + + runner.reload + + expect(response).to have_gitlab_http_status(302) + expect(runner.active).to eq(false) + end + end +end diff --git a/spec/controllers/projects/clusters/gcp_controller_spec.rb b/spec/controllers/projects/clusters/gcp_controller_spec.rb index e14ba29fa70..715bb9f5e52 100644 --- a/spec/controllers/projects/clusters/gcp_controller_spec.rb +++ b/spec/controllers/projects/clusters/gcp_controller_spec.rb @@ -142,7 +142,7 @@ describe Projects::Clusters::GcpController do context 'when google project billing is enabled' do before do - redis_double = double + redis_double = double.as_null_object allow(Gitlab::Redis::SharedState).to receive(:with).and_yield(redis_double) allow(redis_double).to receive(:get).with(CheckGcpProjectBillingWorker.redis_shared_state_key_for('token')).and_return('true') end diff --git a/spec/controllers/projects/compare_controller_spec.rb b/spec/controllers/projects/compare_controller_spec.rb index 046ce027965..b15cde4314e 100644 --- a/spec/controllers/projects/compare_controller_spec.rb +++ b/spec/controllers/projects/compare_controller_spec.rb @@ -3,96 +3,99 @@ require 'spec_helper' describe Projects::CompareController do let(:project) { create(:project, :repository) } let(:user) { create(:user) } - let(:ref_from) { "improve%2Fawesome" } - let(:ref_to) { "feature" } before do sign_in(user) project.add_master(user) end - it 'compare shows some diffs' do - get(:show, - namespace_id: project.namespace, - project_id: project, - from: ref_from, - to: ref_to) + describe 'GET index' do + render_views + + before do + get :index, namespace_id: project.namespace, project_id: project + end - expect(response).to be_success - expect(assigns(:diffs).diff_files.first).not_to be_nil - expect(assigns(:commits).length).to be >= 1 + it 'returns successfully' do + expect(response).to be_success + end end - it 'compare shows some diffs with ignore whitespace change option' do - get(:show, + describe 'GET show' do + render_views + + subject(:show_request) { get :show, request_params } + + let(:request_params) do + { namespace_id: project.namespace, project_id: project, - from: '08f22f25', - to: '66eceea0', - w: 1) - - expect(response).to be_success - diff_file = assigns(:diffs).diff_files.first - expect(diff_file).not_to be_nil - expect(assigns(:commits).length).to be >= 1 - # without whitespace option, there are more than 2 diff_splits - diff_splits = diff_file.diff.diff.split("\n") - expect(diff_splits.length).to be <= 2 - end + from: source_ref, + to: target_ref, + w: whitespace + } + end - describe 'non-existent refs' do - it 'uses invalid source ref' do - get(:show, - namespace_id: project.namespace, - project_id: project, - from: 'non-existent', - to: ref_to) + let(:whitespace) { nil } - expect(response).to be_success - expect(assigns(:diffs).diff_files.to_a).to eq([]) - expect(assigns(:commits)).to eq([]) - end + context 'when the refs exist' do + context 'when we set the white space param' do + let(:source_ref) { "08f22f25" } + let(:target_ref) { "66eceea0" } + let(:whitespace) { 1 } - it 'uses invalid target ref' do - get(:show, - namespace_id: project.namespace, - project_id: project, - from: ref_from, - to: 'non-existent') + it 'shows some diffs with ignore whitespace change option' do + show_request - expect(response).to be_success - expect(assigns(:diffs)).to eq(nil) - expect(assigns(:commits)).to eq(nil) - end + expect(response).to be_success + diff_file = assigns(:diffs).diff_files.first + expect(diff_file).not_to be_nil + expect(assigns(:commits).length).to be >= 1 + # without whitespace option, there are more than 2 diff_splits + diff_splits = diff_file.diff.diff.split("\n") + expect(diff_splits.length).to be <= 2 + end + end + + context 'when we do not set the white space param' do + let(:source_ref) { "improve%2Fawesome" } + let(:target_ref) { "feature" } + let(:whitespace) { nil } - it 'redirects back to index when params[:from] is empty and preserves params[:to]' do - post(:create, - namespace_id: project.namespace, - project_id: project, - from: '', - to: 'master') + it 'sets the diffs and commits ivars' do + show_request - expect(response).to redirect_to(project_compare_index_path(project, to: 'master')) + expect(response).to be_success + expect(assigns(:diffs).diff_files.first).not_to be_nil + expect(assigns(:commits).length).to be >= 1 + end + end end - it 'redirects back to index when params[:to] is empty and preserves params[:from]' do - post(:create, - namespace_id: project.namespace, - project_id: project, - from: 'master', - to: '') + context 'when the source ref does not exist' do + let(:source_ref) { 'non-existent-source-ref' } + let(:target_ref) { "feature" } + + it 'sets empty diff and commit ivars' do + show_request - expect(response).to redirect_to(project_compare_index_path(project, from: 'master')) + expect(response).to be_success + expect(assigns(:diffs).diff_files.to_a).to eq([]) + expect(assigns(:commits)).to eq([]) + end end - it 'redirects back to index when params[:from] and params[:to] are empty' do - post(:create, - namespace_id: project.namespace, - project_id: project, - from: '', - to: '') + context 'when the target ref does not exist' do + let(:target_ref) { 'non-existent-target-ref' } + let(:source_ref) { "improve%2Fawesome" } - expect(response).to redirect_to(namespace_project_compare_index_path) + it 'sets empty diff and commit ivars' do + show_request + + expect(response).to be_success + expect(assigns(:diffs)).to eq([]) + expect(assigns(:commits)).to eq([]) + end end end @@ -107,12 +110,14 @@ describe Projects::CompareController do end let(:existing_path) { 'files/ruby/feature.rb' } + let(:source_ref) { "improve%2Fawesome" } + let(:target_ref) { "feature" } - context 'when the from and to refs exist' do - context 'when the user has access to the project' do + context 'when the source and target refs exist' do + context 'when the user has access target the project' do context 'when the path exists in the diff' do it 'disables diff notes' do - diff_for_path(from: ref_from, to: ref_to, old_path: existing_path, new_path: existing_path) + diff_for_path(from: source_ref, to: target_ref, old_path: existing_path, new_path: existing_path) expect(assigns(:diff_notes_disabled)).to be_truthy end @@ -123,13 +128,13 @@ describe Projects::CompareController do meth.call(diffs) end - diff_for_path(from: ref_from, to: ref_to, old_path: existing_path, new_path: existing_path) + diff_for_path(from: source_ref, to: target_ref, old_path: existing_path, new_path: existing_path) end end context 'when the path does not exist in the diff' do before do - diff_for_path(from: ref_from, to: ref_to, old_path: existing_path.succ, new_path: existing_path.succ) + diff_for_path(from: source_ref, to: target_ref, old_path: existing_path.succ, new_path: existing_path.succ) end it 'returns a 404' do @@ -138,10 +143,10 @@ describe Projects::CompareController do end end - context 'when the user does not have access to the project' do + context 'when the user does not have access target the project' do before do project.team.truncate - diff_for_path(from: ref_from, to: ref_to, old_path: existing_path, new_path: existing_path) + diff_for_path(from: source_ref, to: target_ref, old_path: existing_path, new_path: existing_path) end it 'returns a 404' do @@ -150,9 +155,9 @@ describe Projects::CompareController do end end - context 'when the from ref does not exist' do + context 'when the source ref does not exist' do before do - diff_for_path(from: ref_from.succ, to: ref_to, old_path: existing_path, new_path: existing_path) + diff_for_path(from: source_ref.succ, to: target_ref, old_path: existing_path, new_path: existing_path) end it 'returns a 404' do @@ -160,9 +165,9 @@ describe Projects::CompareController do end end - context 'when the to ref does not exist' do + context 'when the target ref does not exist' do before do - diff_for_path(from: ref_from, to: ref_to.succ, old_path: existing_path, new_path: existing_path) + diff_for_path(from: source_ref, to: target_ref.succ, old_path: existing_path, new_path: existing_path) end it 'returns a 404' do @@ -170,4 +175,153 @@ describe Projects::CompareController do end end end + + describe 'POST create' do + subject(:create_request) { post :create, request_params } + + let(:request_params) do + { + namespace_id: project.namespace, + project_id: project, + from: source_ref, + to: target_ref + } + end + + context 'when sending valid params' do + let(:source_ref) { "improve%2Fawesome" } + let(:target_ref) { "feature" } + + it 'redirects back to show' do + create_request + + expect(response).to redirect_to(project_compare_path(project, to: target_ref, from: source_ref)) + end + end + + context 'when sending invalid params' do + context 'when the source ref is empty and target ref is set' do + let(:source_ref) { '' } + let(:target_ref) { 'master' } + + it 'redirects back to index and preserves the target ref' do + create_request + + expect(response).to redirect_to(project_compare_index_path(project, to: target_ref)) + end + end + + context 'when the target ref is empty and source ref is set' do + let(:source_ref) { 'master' } + let(:target_ref) { '' } + + it 'redirects back to index and preserves source ref' do + create_request + + expect(response).to redirect_to(project_compare_index_path(project, from: source_ref)) + end + end + + context 'when the target and source ref are empty' do + let(:source_ref) { '' } + let(:target_ref) { '' } + + it 'redirects back to index' do + create_request + + expect(response).to redirect_to(namespace_project_compare_index_path) + end + end + end + end + + describe 'GET signatures' do + subject(:signatures_request) { get :signatures, request_params } + + let(:request_params) do + { + namespace_id: project.namespace, + project_id: project, + from: source_ref, + to: target_ref, + format: :json + } + end + + context 'when the source and target refs exist' do + let(:source_ref) { "improve%2Fawesome" } + let(:target_ref) { "feature" } + + context 'when the user has access to the project' do + render_views + + let(:signature_commit) { build(:commit, project: project, safe_message: "message", sha: 'signature_commit') } + let(:non_signature_commit) { build(:commit, project: project, safe_message: "message", sha: 'non_signature_commit') } + + before do + escaped_source_ref = Addressable::URI.unescape(source_ref) + escaped_target_ref = Addressable::URI.unescape(target_ref) + + compare_service = CompareService.new(project, escaped_target_ref) + compare = compare_service.execute(project, escaped_source_ref) + + expect(CompareService).to receive(:new).with(project, escaped_target_ref).and_return(compare_service) + expect(compare_service).to receive(:execute).with(project, escaped_source_ref).and_return(compare) + + expect(compare).to receive(:commits).and_return([signature_commit, non_signature_commit]) + expect(non_signature_commit).to receive(:has_signature?).and_return(false) + end + + it 'returns only the commit with a signature' do + signatures_request + + expect(response).to have_gitlab_http_status(200) + parsed_body = JSON.parse(response.body) + signatures = parsed_body['signatures'] + + expect(signatures.size).to eq(1) + expect(signatures.first['commit_sha']).to eq(signature_commit.sha) + expect(signatures.first['html']).to be_present + end + end + + context 'when the user does not have access to the project' do + before do + project.team.truncate + end + + it 'returns a 404' do + signatures_request + + expect(response).to have_gitlab_http_status(404) + end + end + end + + context 'when the source ref does not exist' do + let(:source_ref) { 'non-existent-ref-source' } + let(:target_ref) { "feature" } + + it 'returns no signatures' do + signatures_request + + expect(response).to have_gitlab_http_status(200) + parsed_body = JSON.parse(response.body) + expect(parsed_body['signatures']).to be_empty + end + end + + context 'when the target ref does not exist' do + let(:target_ref) { 'non-existent-ref-target' } + let(:source_ref) { "improve%2Fawesome" } + + it 'returns no signatures' do + signatures_request + + expect(response).to have_gitlab_http_status(200) + parsed_body = JSON.parse(response.body) + expect(parsed_body['signatures']).to be_empty + end + end + end end diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb index b9a979044fe..2281cb420d9 100644 --- a/spec/controllers/projects/jobs_controller_spec.rb +++ b/spec/controllers/projects/jobs_controller_spec.rb @@ -1,7 +1,7 @@ # coding: utf-8 require 'spec_helper' -describe Projects::JobsController do +describe Projects::JobsController, :clean_gitlab_redis_shared_state do include ApiHelpers include HttpIOHelpers @@ -10,6 +10,7 @@ describe Projects::JobsController do let(:user) { create(:user) } before do + stub_feature_flags(ci_enable_live_trace: true) stub_not_protect_default_branch end diff --git a/spec/controllers/projects/merge_requests/creations_controller_spec.rb b/spec/controllers/projects/merge_requests/creations_controller_spec.rb index 24310b847e8..00d76f3c39a 100644 --- a/spec/controllers/projects/merge_requests/creations_controller_spec.rb +++ b/spec/controllers/projects/merge_requests/creations_controller_spec.rb @@ -157,34 +157,4 @@ describe Projects::MergeRequests::CreationsController do expect(response).to have_gitlab_http_status(200) end end - - describe 'GET #update_branches' do - before do - allow(Ability).to receive(:allowed?).and_call_original - end - - it 'lists the branches of another fork if the user has access' do - expect(Ability).to receive(:allowed?).with(user, :read_project, project) { true } - - get :update_branches, - namespace_id: fork_project.namespace, - project_id: fork_project, - target_project_id: project.id - - expect(assigns(:target_branches)).not_to be_empty - expect(response).to have_gitlab_http_status(200) - end - - it 'does not list branches when the user cannot read the project' do - expect(Ability).to receive(:allowed?).with(user, :read_project, project) { false } - - get :update_branches, - namespace_id: fork_project.namespace, - project_id: fork_project, - target_project_id: project.id - - expect(response).to have_gitlab_http_status(200) - expect(assigns(:target_branches)).to eq([]) - end - end end diff --git a/spec/controllers/projects/mirrors_controller_spec.rb b/spec/controllers/projects/mirrors_controller_spec.rb new file mode 100644 index 00000000000..45c1218a39c --- /dev/null +++ b/spec/controllers/projects/mirrors_controller_spec.rb @@ -0,0 +1,72 @@ +require 'spec_helper' + +describe Projects::MirrorsController do + include ReactiveCachingHelpers + + describe 'setting up a remote mirror' do + set(:project) { create(:project, :repository) } + + context 'when the current project is not a mirror' do + it 'allows to create a remote mirror' do + sign_in(project.owner) + + expect do + do_put(project, remote_mirrors_attributes: { '0' => { 'enabled' => 1, 'url' => 'http://foo.com' } }) + end.to change { RemoteMirror.count }.to(1) + end + end + end + + describe '#update' do + let(:project) { create(:project, :repository, :remote_mirror) } + + before do + sign_in(project.owner) + end + + around do |example| + Sidekiq::Testing.fake! { example.run } + end + + context 'With valid URL for a push' do + let(:remote_mirror_attributes) do + { "0" => { "enabled" => "0", url: 'https://updated.example.com' } } + end + + it 'processes a successful update' do + do_put(project, remote_mirrors_attributes: remote_mirror_attributes) + + expect(response).to redirect_to(project_settings_repository_path(project)) + expect(flash[:notice]).to match(/successfully updated/) + end + + it 'should create a RemoteMirror object' do + expect { do_put(project, remote_mirrors_attributes: remote_mirror_attributes) }.to change(RemoteMirror, :count).by(1) + end + end + + context 'With invalid URL for a push' do + let(:remote_mirror_attributes) do + { "0" => { "enabled" => "0", url: 'ftp://invalid.invalid' } } + end + + it 'processes an unsuccessful update' do + do_put(project, remote_mirrors_attributes: remote_mirror_attributes) + + expect(response).to redirect_to(project_settings_repository_path(project)) + expect(flash[:alert]).to match(/must be a valid URL/) + end + + it 'should not create a RemoteMirror object' do + expect { do_put(project, remote_mirrors_attributes: remote_mirror_attributes) }.not_to change(RemoteMirror, :count) + end + end + end + + def do_put(project, options, extra_attrs = {}) + attrs = extra_attrs.merge(namespace_id: project.namespace.to_param, project_id: project.to_param) + attrs[:project] = options + + put :update, attrs + end +end diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb index 35ac999cc65..a451bbb97b6 100644 --- a/spec/controllers/projects/pipelines_controller_spec.rb +++ b/spec/controllers/projects/pipelines_controller_spec.rb @@ -109,8 +109,7 @@ describe Projects::PipelinesController do it 'returns html source for stage dropdown' do expect(response).to have_gitlab_http_status(:ok) - expect(response).to render_template('projects/pipelines/_stage') - expect(json_response).to include('html') + expect(response).to match_response_schema('pipeline_stage') end end @@ -133,6 +132,42 @@ describe Projects::PipelinesController do end end + describe 'GET stages_ajax.json' do + let(:pipeline) { create(:ci_pipeline, project: project) } + + context 'when accessing existing stage' do + before do + create(:ci_build, pipeline: pipeline, stage: 'build') + + get_stage_ajax('build') + end + + it 'returns html source for stage dropdown' do + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template('projects/pipelines/_stage') + expect(json_response).to include('html') + end + end + + context 'when accessing unknown stage' do + before do + get_stage_ajax('test') + end + + it 'responds with not found' do + expect(response).to have_gitlab_http_status(:not_found) + end + end + + def get_stage_ajax(name) + get :stage_ajax, namespace_id: project.namespace, + project_id: project, + id: pipeline.id, + stage: name, + format: :json + end + end + describe 'GET status.json' do let(:pipeline) { create(:ci_pipeline, project: project) } let(:status) { pipeline.detailed_status(double('user')) } diff --git a/spec/controllers/projects/raw_controller_spec.rb b/spec/controllers/projects/raw_controller_spec.rb index 08e2ccf893a..c3468536ae1 100644 --- a/spec/controllers/projects/raw_controller_spec.rb +++ b/spec/controllers/projects/raw_controller_spec.rb @@ -54,9 +54,9 @@ describe Projects::RawController do end context 'and lfs uses object storage' do + let(:lfs_object) { create(:lfs_object, :with_file, oid: '91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897', size: '1575078') } + before do - lfs_object.file = fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "`/png") - lfs_object.save! stub_lfs_object_storage lfs_object.file.migrate!(LfsObjectUploader::Store::REMOTE) end diff --git a/spec/controllers/projects/settings/ci_cd_controller_spec.rb b/spec/controllers/projects/settings/ci_cd_controller_spec.rb index 7dae9b85d78..a91c868cbaf 100644 --- a/spec/controllers/projects/settings/ci_cd_controller_spec.rb +++ b/spec/controllers/projects/settings/ci_cd_controller_spec.rb @@ -17,6 +17,23 @@ describe Projects::Settings::CiCdController do expect(response).to have_gitlab_http_status(200) expect(response).to render_template(:show) end + + context 'with group runners' do + let(:group_runner) { create(:ci_runner) } + let(:parent_group) { create(:group) } + let(:group) { create(:group, runners: [group_runner], parent: parent_group) } + let(:other_project) { create(:project, group: group) } + let!(:project_runner) { create(:ci_runner, projects: [other_project]) } + let!(:shared_runner) { create(:ci_runner, :shared) } + + it 'sets assignable project runners only' do + group.add_master(user) + + get :show, namespace_id: project.namespace, project_id: project + + expect(assigns(:assignable_runners)).to eq [project_runner] + end + end end describe '#reset_cache' do diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb index 55bd4352bd3..555b186fe31 100644 --- a/spec/controllers/sessions_controller_spec.rb +++ b/spec/controllers/sessions_controller_spec.rb @@ -265,7 +265,7 @@ describe SessionsController do it 'redirects correctly for referer on same host with params' do search_path = '/search?search=seed_project' allow(controller.request).to receive(:referer) - .and_return('http://%{host}%{path}' % { host: Gitlab.config.gitlab.host, path: search_path }) + .and_return('http://%{host}%{path}' % { host: 'test.host', path: search_path }) get(:new, redirect_to_referer: :yes) diff --git a/spec/controllers/users/terms_controller_spec.rb b/spec/controllers/users/terms_controller_spec.rb new file mode 100644 index 00000000000..a744463413c --- /dev/null +++ b/spec/controllers/users/terms_controller_spec.rb @@ -0,0 +1,81 @@ +require 'spec_helper' + +describe Users::TermsController do + let(:user) { create(:user) } + let(:term) { create(:term) } + + before do + sign_in user + end + + describe 'GET #index' do + it 'redirects when no terms exist' do + get :index + + expect(response).to have_gitlab_http_status(:redirect) + end + + it 'shows terms when they exist' do + term + + expect(response).to have_gitlab_http_status(:success) + end + end + + describe 'POST #accept' do + it 'saves that the user accepted the terms' do + post :accept, id: term.id + + agreement = user.term_agreements.find_by(term: term) + + expect(agreement.accepted).to eq(true) + end + + it 'redirects to a path when specified' do + post :accept, id: term.id, redirect: groups_path + + expect(response).to redirect_to(groups_path) + end + + it 'redirects to the referer when no redirect specified' do + request.env["HTTP_REFERER"] = groups_url + + post :accept, id: term.id + + expect(response).to redirect_to(groups_path) + end + + context 'redirecting to another domain' do + it 'is prevented when passing a redirect param' do + post :accept, id: term.id, redirect: '//example.com/random/path' + + expect(response).to redirect_to(root_path) + end + + it 'is prevented when redirecting to the referer' do + request.env["HTTP_REFERER"] = 'http://example.com/and/a/path' + + post :accept, id: term.id + + expect(response).to redirect_to(root_path) + end + end + end + + describe 'POST #decline' do + it 'stores that the user declined the terms' do + post :decline, id: term.id + + agreement = user.term_agreements.find_by(term: term) + + expect(agreement.accepted).to eq(false) + end + + it 'signs out the user' do + post :decline, id: term.id + + expect(response).to redirect_to(root_path) + expect(assigns(:current_user)).to be_nil + end + end +end diff --git a/spec/db/production/settings_spec.rb b/spec/db/production/settings_spec.rb index c8d016070f5..db19e98b851 100644 --- a/spec/db/production/settings_spec.rb +++ b/spec/db/production/settings_spec.rb @@ -48,15 +48,15 @@ describe 'seed production settings' do end end - context 'GITLAB_PROMETHEUS_METRICS_ENABLED is false' do + context 'GITLAB_PROMETHEUS_METRICS_ENABLED is default' do before do stub_env('GITLAB_PROMETHEUS_METRICS_ENABLED', '') end - it 'prometheus_metrics_enabled is set to false' do + it 'prometheus_metrics_enabled is set to true' do load(settings_file) - expect(settings.prometheus_metrics_enabled).to eq(false) + expect(settings.prometheus_metrics_enabled).to eq(true) end end end diff --git a/spec/factories/ci/build_trace_chunks.rb b/spec/factories/ci/build_trace_chunks.rb new file mode 100644 index 00000000000..c0b9a25bfe8 --- /dev/null +++ b/spec/factories/ci/build_trace_chunks.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :ci_build_trace_chunk, class: Ci::BuildTraceChunk do + build factory: :ci_build + chunk_index 0 + data_store :redis + end +end diff --git a/spec/factories/ci/stages.rb b/spec/factories/ci/stages.rb index 25309033571..ce61e6bf759 100644 --- a/spec/factories/ci/stages.rb +++ b/spec/factories/ci/stages.rb @@ -21,6 +21,7 @@ FactoryBot.define do pipeline factory: :ci_empty_pipeline name 'test' + position 1 status 'pending' end end diff --git a/spec/factories/commit_statuses.rb b/spec/factories/commit_statuses.rb index ce5fbc343ee..53368c64e10 100644 --- a/spec/factories/commit_statuses.rb +++ b/spec/factories/commit_statuses.rb @@ -2,6 +2,7 @@ FactoryBot.define do factory :commit_status, class: CommitStatus do name 'default' stage 'test' + stage_idx 0 status 'success' description 'commit status' pipeline factory: :ci_pipeline_with_one_job diff --git a/spec/factories/import_state.rb b/spec/factories/import_state.rb new file mode 100644 index 00000000000..15d0a9d466a --- /dev/null +++ b/spec/factories/import_state.rb @@ -0,0 +1,38 @@ +FactoryBot.define do + factory :import_state, class: ProjectImportState do + status :none + association :project, factory: :project + + transient do + import_url { generate(:url) } + end + + trait :repository do + association :project, factory: [:project, :repository] + end + + trait :none do + status :none + end + + trait :scheduled do + status :scheduled + end + + trait :started do + status :started + end + + trait :finished do + status :finished + end + + trait :failed do + status :failed + end + + after(:create) do |import_state, evaluator| + import_state.project.update_columns(import_url: evaluator.import_url) + end + end +end diff --git a/spec/factories/project_wikis.rb b/spec/factories/project_wikis.rb index db2eb4fc863..4d21ed47f39 100644 --- a/spec/factories/project_wikis.rb +++ b/spec/factories/project_wikis.rb @@ -2,7 +2,7 @@ FactoryBot.define do factory :project_wiki do skip_create - project + association :project, :wiki_repo user { project.creator } initialize_with { new(project, user) } end diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 1904615778c..16e025618a6 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -15,14 +15,18 @@ FactoryBot.define do namespace creator { group ? create(:user) : namespace&.owner } - # Nest Project Feature attributes transient do + # Nest Project Feature attributes wiki_access_level ProjectFeature::ENABLED builds_access_level ProjectFeature::ENABLED snippets_access_level ProjectFeature::ENABLED issues_access_level ProjectFeature::ENABLED merge_requests_access_level ProjectFeature::ENABLED repository_access_level ProjectFeature::ENABLED + + # we can't assign the delegated `#ci_cd_settings` attributes directly, as the + # `#ci_cd_settings` relation needs to be created first + group_runners_enabled nil end after(:create) do |project, evaluator| @@ -47,6 +51,9 @@ FactoryBot.define do end project.group&.refresh_members_authorized_projects + + # assign the delegated `#ci_cd_settings` attributes after create + project.reload.group_runners_enabled = evaluator.group_runners_enabled unless evaluator.group_runners_enabled.nil? end trait :public do @@ -152,6 +159,17 @@ FactoryBot.define do end end + trait :remote_mirror do + transient do + remote_name "remote_mirror_#{SecureRandom.hex}" + url "http://foo.com" + enabled true + end + after(:create) do |project, evaluator| + project.remote_mirrors.create!(url: evaluator.url, enabled: evaluator.enabled) + end + end + trait :stubbed_repository do after(:build) do |project| allow(project).to receive(:empty_repo?).and_return(false) @@ -162,6 +180,13 @@ FactoryBot.define do trait :wiki_repo do after(:create) do |project| raise 'Failed to create wiki repository!' unless project.create_wiki + + # We delete hooks so that gitlab-shell will not try to authenticate with + # an API that isn't running + project.gitlab_shell.rm_directory( + project.repository_storage, + File.join("#{project.wiki.repository.disk_path}.git", "hooks") + ) end end diff --git a/spec/factories/remote_mirrors.rb b/spec/factories/remote_mirrors.rb new file mode 100644 index 00000000000..adc7da27522 --- /dev/null +++ b/spec/factories/remote_mirrors.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :remote_mirror, class: 'RemoteMirror' do + association :project, :repository + url "http://foo:bar@test.com" + end +end diff --git a/spec/factories/term_agreements.rb b/spec/factories/term_agreements.rb new file mode 100644 index 00000000000..557599e663d --- /dev/null +++ b/spec/factories/term_agreements.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :term_agreement do + term + user + end +end diff --git a/spec/factories/terms.rb b/spec/factories/terms.rb new file mode 100644 index 00000000000..5ffca365a5f --- /dev/null +++ b/spec/factories/terms.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :term, class: ApplicationSetting::Term do + terms "Lorem ipsum dolor sit amet, consectetur adipiscing elit." + end +end diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb index 8de2e3d199b..3465ccfc423 100644 --- a/spec/features/admin/admin_runners_spec.rb +++ b/spec/features/admin/admin_runners_spec.rb @@ -59,6 +59,47 @@ describe "Admin Runners" do expect(page).to have_text 'No runners found' end end + + context 'group runner' do + let(:group) { create(:group) } + let!(:runner) { create(:ci_runner, groups: [group], runner_type: :group_type) } + + it 'shows the label and does not show the project count' do + visit admin_runners_path + + within "#runner_#{runner.id}" do + expect(page).to have_selector '.label', text: 'group' + expect(page).to have_text 'n/a' + end + end + end + + context 'shared runner' do + it 'shows the label and does not show the project count' do + runner = create :ci_runner, :shared + + visit admin_runners_path + + within "#runner_#{runner.id}" do + expect(page).to have_selector '.label', text: 'shared' + expect(page).to have_text 'n/a' + end + end + end + + context 'specific runner' do + it 'shows the label and the project count' do + project = create :project + runner = create :ci_runner, projects: [project] + + visit admin_runners_path + + within "#runner_#{runner.id}" do + expect(page).to have_selector '.label', text: 'specific' + expect(page).to have_text '1' + end + end + end end describe "Runner show page" do diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index 7853d2952ea..f2f9b734c39 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -2,10 +2,13 @@ require 'spec_helper' feature 'Admin updates settings' do include StubENV + include TermsHelper + + let(:admin) { create(:admin) } before do stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') - sign_in(create(:admin)) + sign_in(admin) visit admin_application_settings_path end @@ -85,6 +88,22 @@ feature 'Admin updates settings' do expect(page).to have_content "Application settings saved successfully" end + scenario 'Terms of Service' do + # Already have the admin accept terms, so they don't need to accept in this spec. + _existing_terms = create(:term) + accept_terms(admin) + + page.within('.as-terms') do + check 'Require all users to accept Terms of Service when they access GitLab.' + fill_in 'Terms of Service Agreement', with: 'Be nice!' + click_button 'Save changes' + end + + expect(Gitlab::CurrentSettings.enforce_terms).to be(true) + expect(Gitlab::CurrentSettings.terms).to eq 'Be nice!' + expect(page).to have_content 'Application settings saved successfully' + end + scenario 'Modify oauth providers' do expect(Gitlab::CurrentSettings.disabled_oauth_sign_in_sources).to be_empty diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb index 9ec8bb0aefe..9e3221577c7 100644 --- a/spec/features/admin/admin_users_spec.rb +++ b/spec/features/admin/admin_users_spec.rb @@ -285,7 +285,7 @@ describe "Admin::Users" do it "lists group projects" do within(:css, '.append-bottom-default + .card') do expect(page).to have_content 'Group projects' - expect(page).to have_link group.name, admin_group_path(group) + expect(page).to have_link group.name, href: admin_group_path(group) end end diff --git a/spec/features/admin/admin_uses_repository_checks_spec.rb b/spec/features/admin/admin_uses_repository_checks_spec.rb index f1ac73ff819..90cf5a53787 100644 --- a/spec/features/admin/admin_uses_repository_checks_spec.rb +++ b/spec/features/admin/admin_uses_repository_checks_spec.rb @@ -19,7 +19,7 @@ feature 'Admin uses repository checks' do expect(page).to have_content('Repository check was triggered') end - scenario 'to see a single failed repository check' do + scenario 'to see a single failed repository check', :js do project = create(:project) project.update_columns( last_repository_check_failed: true, diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb index ff2a0e15719..fd0aa6cf3a3 100644 --- a/spec/features/issues/user_uses_slash_commands_spec.rb +++ b/spec/features/issues/user_uses_slash_commands_spec.rb @@ -161,6 +161,7 @@ feature 'Issues > User uses quick actions', :js do before do target_project.add_master(user) + gitlab_sign_out sign_in(user) visit project_issue_path(project, issue) end @@ -178,9 +179,10 @@ feature 'Issues > User uses quick actions', :js do end context 'when the project is valid but the user not authorized' do - let(:project_unauthorized) {create(:project, :public)} + let(:project_unauthorized) { create(:project, :public) } before do + gitlab_sign_out sign_in(user) visit project_issue_path(project, issue) end @@ -195,6 +197,7 @@ feature 'Issues > User uses quick actions', :js do context 'when the project is invalid' do before do + gitlab_sign_out sign_in(user) visit project_issue_path(project, issue) end @@ -218,6 +221,7 @@ feature 'Issues > User uses quick actions', :js do before do target_project.add_master(user) + gitlab_sign_out sign_in(user) visit project_issue_path(project, issue) end diff --git a/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb b/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb index dbca279569a..42c279af117 100644 --- a/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb +++ b/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb @@ -19,7 +19,7 @@ describe 'Merge request > User selects branches for new MR', :js do expect(page).to have_content('Target branch') first('.js-source-branch').click - find('.dropdown-source-branch .dropdown-content a', match: :first).click + find('.js-source-branch-dropdown .dropdown-content a', match: :first).click expect(page).to have_content "b83d6e3" end @@ -35,22 +35,16 @@ describe 'Merge request > User selects branches for new MR', :js do expect(page).to have_content('Target branch') first('.js-target-branch').click - find('.dropdown-target-branch .dropdown-content a', text: 'v1.1.0', match: :first).click + find('.js-target-branch-dropdown .dropdown-content a', text: 'v1.1.0', match: :first).click expect(page).to have_content "b83d6e3" end it 'generates a diff for an orphaned branch' do - visit project_merge_requests_path(project) - - page.within '.content' do - click_link 'New merge request' - end - expect(page).to have_content('Source branch') - expect(page).to have_content('Target branch') + visit project_new_merge_request_path(project) find('.js-source-branch', match: :first).click - find('.dropdown-source-branch .dropdown-content a', text: 'orphaned-branch', match: :first).click + find('.js-source-branch-dropdown .dropdown-content a', text: 'orphaned-branch', match: :first).click click_button "Compare branches" click_link "Changes" @@ -71,19 +65,18 @@ describe 'Merge request > User selects branches for new MR', :js do first('.js-source-branch').click - input = find('.dropdown-source-branch .dropdown-input-field') - input.click - input.send_keys('orphaned-branch') + page.within '.js-source-branch-dropdown' do + input = find('.dropdown-input-field') + input.click + input.send_keys('orphaned-branch') - find('.dropdown-source-branch .dropdown-content li', match: :first) - source_items = all('.dropdown-source-branch .dropdown-content li') - - expect(source_items.count).to eq(1) + expect(page).to have_css('.dropdown-content li', count: 1) + end first('.js-target-branch').click - find('.dropdown-target-branch .dropdown-content li', match: :first) - target_items = all('.dropdown-target-branch .dropdown-content li') + find('.js-target-branch-dropdown .dropdown-content li', match: :first) + target_items = all('.js-target-branch-dropdown .dropdown-content li') expect(target_items.count).to be > 1 end @@ -171,7 +164,6 @@ describe 'Merge request > User selects branches for new MR', :js do page.within('.merge-request') do click_link 'Pipelines' - wait_for_requests expect(page).to have_content "##{pipeline.id}" end diff --git a/spec/features/profiles/active_sessions_spec.rb b/spec/features/profiles/active_sessions_spec.rb new file mode 100644 index 00000000000..4045cfd21c4 --- /dev/null +++ b/spec/features/profiles/active_sessions_spec.rb @@ -0,0 +1,89 @@ +require 'rails_helper' + +feature 'Profile > Active Sessions', :clean_gitlab_redis_shared_state do + let(:user) do + create(:user).tap do |user| + user.current_sign_in_at = Time.current + end + end + + around do |example| + Timecop.freeze(Time.zone.parse('2018-03-12 09:06')) do + example.run + end + end + + scenario 'User sees their active sessions' do + Capybara::Session.new(:session1) + Capybara::Session.new(:session2) + + # note: headers can only be set on the non-js (aka. rack-test) driver + using_session :session1 do + Capybara.page.driver.header( + 'User-Agent', + 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:58.0) Gecko/20100101 Firefox/58.0' + ) + + gitlab_sign_in(user) + end + + # set an additional session on another device + using_session :session2 do + Capybara.page.driver.header( + 'User-Agent', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 8_1_3 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Mobile/12B466 [FBDV/iPhone7,2]' + ) + + gitlab_sign_in(user) + end + + using_session :session1 do + visit profile_active_sessions_path + + expect(page).to have_content( + '127.0.0.1 ' \ + 'This is your current session ' \ + 'Firefox on Ubuntu ' \ + 'Signed in on 12 Mar 09:06' + ) + + expect(page).to have_selector '[title="Desktop"]', count: 1 + + expect(page).to have_content( + '127.0.0.1 ' \ + 'Last accessed on 12 Mar 09:06 ' \ + 'Mobile Safari on iOS ' \ + 'Signed in on 12 Mar 09:06' + ) + + expect(page).to have_selector '[title="Smartphone"]', count: 1 + end + end + + scenario 'User can revoke a session', :js, :redis_session_store do + Capybara::Session.new(:session1) + Capybara::Session.new(:session2) + + # set an additional session in another browser + using_session :session2 do + gitlab_sign_in(user) + end + + using_session :session1 do + gitlab_sign_in(user) + visit profile_active_sessions_path + + expect(page).to have_link('Revoke', count: 1) + + accept_confirm { click_on 'Revoke' } + + expect(page).not_to have_link('Revoke') + end + + using_session :session2 do + visit profile_active_sessions_path + + expect(page).to have_content('You need to sign in or sign up before continuing.') + end + end +end diff --git a/spec/features/projects/artifacts/browse_spec.rb b/spec/features/projects/artifacts/browse_spec.rb deleted file mode 100644 index cb69aff8d5f..00000000000 --- a/spec/features/projects/artifacts/browse_spec.rb +++ /dev/null @@ -1,67 +0,0 @@ -require 'spec_helper' - -feature 'Browse artifact', :js do - let(:project) { create(:project, :public) } - let(:pipeline) { create(:ci_empty_pipeline, project: project) } - let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) } - let(:browse_url) do - browse_path('other_artifacts_0.1.2') - end - - def browse_path(path) - browse_project_job_artifacts_path(project, job, path) - end - - context 'when visiting old URL' do - before do - visit browse_url.sub('/-/jobs', '/builds') - end - - it "redirects to new URL" do - expect(page.current_path).to eq(browse_url) - end - end - - context 'when browsing a directory with an text file' do - let(:txt_entry) { job.artifacts_metadata_entry('other_artifacts_0.1.2/doc_sample.txt') } - - before do - allow(Gitlab.config.pages).to receive(:enabled).and_return(true) - allow(Gitlab.config.pages).to receive(:artifacts_server).and_return(true) - end - - context 'when the project is public' do - it "shows external link icon and styles" do - visit browse_url - - link = first('.tree-item-file-external-link') - - expect(page).to have_link('doc_sample.txt', href: file_project_job_artifacts_path(project, job, path: txt_entry.blob.path)) - expect(link[:target]).to eq('_blank') - expect(link[:rel]).to include('noopener') - expect(link[:rel]).to include('noreferrer') - expect(page).to have_selector('.js-artifact-tree-external-icon') - end - end - - context 'when the project is private' do - let!(:private_project) { create(:project, :private) } - let(:pipeline) { create(:ci_empty_pipeline, project: private_project) } - let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) } - let(:user) { create(:user) } - - before do - private_project.add_developer(user) - - sign_in(user) - end - - it 'shows internal link styles' do - visit browse_project_job_artifacts_path(private_project, job, 'other_artifacts_0.1.2') - - expect(page).to have_link('doc_sample.txt') - expect(page).not_to have_selector('.js-artifact-tree-external-icon') - end - end - end -end diff --git a/spec/features/projects/artifacts/download_spec.rb b/spec/features/projects/artifacts/download_spec.rb deleted file mode 100644 index 6f76c14910b..00000000000 --- a/spec/features/projects/artifacts/download_spec.rb +++ /dev/null @@ -1,61 +0,0 @@ -require 'spec_helper' - -feature 'Download artifact' do - let(:project) { create(:project, :public) } - let(:pipeline) { create(:ci_empty_pipeline, status: :success, project: project) } - let(:job) { create(:ci_build, :artifacts, :success, pipeline: pipeline) } - - shared_examples 'downloading' do - it 'downloads the zip' do - expect(page.response_headers['Content-Disposition']) - .to eq(%Q{attachment; filename="#{job.artifacts_file.filename}"}) - - # Check the content does match, but don't print this as error message - expect(page.source.b == job.artifacts_file.file.read.b) - end - end - - context 'when downloading' do - before do - visit download_url - end - - context 'via job id' do - let(:download_url) do - download_project_job_artifacts_path(project, job) - end - - it_behaves_like 'downloading' - end - - context 'via branch name and job name' do - let(:download_url) do - latest_succeeded_project_artifacts_path(project, "#{pipeline.ref}/download", job: job.name) - end - - it_behaves_like 'downloading' - end - end - - context 'when visiting old URL' do - before do - visit download_url.sub('/-/jobs', '/builds') - end - - context 'via job id' do - let(:download_url) do - download_project_job_artifacts_path(project, job) - end - - it_behaves_like 'downloading' - end - - context 'via branch name and job name' do - let(:download_url) do - latest_succeeded_project_artifacts_path(project, "#{pipeline.ref}/download", job: job.name) - end - - it_behaves_like 'downloading' - end - end -end diff --git a/spec/features/projects/artifacts/user_browses_artifacts_spec.rb b/spec/features/projects/artifacts/user_browses_artifacts_spec.rb new file mode 100644 index 00000000000..9ebbbaea911 --- /dev/null +++ b/spec/features/projects/artifacts/user_browses_artifacts_spec.rb @@ -0,0 +1,110 @@ +require "spec_helper" + +describe "User browses artifacts" do + let(:project) { create(:project, :public) } + let(:pipeline) { create(:ci_empty_pipeline, project: project) } + let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) } + let(:browse_url) { browse_project_job_artifacts_path(project, job, "other_artifacts_0.1.2") } + + context "when visiting old URL" do + it "redirects to new URL" do + visit(browse_url.sub("/-/jobs", "/builds")) + + expect(page.current_path).to eq(browse_url) + end + end + + context "when browsing artifacts root directory" do + before do + visit(browse_project_job_artifacts_path(project, job)) + end + + it "shows artifacts" do + expect(page).not_to have_selector(".build-sidebar") + + page.within(".tree-table") do + expect(page).to have_no_content("..") + .and have_content("other_artifacts_0.1.2") + .and have_content("ci_artifacts.txt") + .and have_content("rails_sample.jpg") + end + + page.within(".build-header") do + expect(page).to have_content("Job ##{job.id} in pipeline ##{pipeline.id} for #{pipeline.short_sha}") + end + end + + it "shows an artifact" do + click_link("ci_artifacts.txt") + + expect(page).to have_link("download it") + end + end + + context "when browsing a directory with UTF-8 characters in its name" do + before do + visit(browse_project_job_artifacts_path(project, job)) + end + + it "shows correct content", :js do + page.within(".tree-table") do + click_link("tests_encoding") + + expect(page).to have_no_content("non-utf8-dir") + + click_link("utf8 test dir ✓") + + expect(page).to have_content("..").and have_content("regular_file_2") + end + end + end + + context "when browsing a directory with a text file" do + let(:txt_entry) { job.artifacts_metadata_entry("other_artifacts_0.1.2/doc_sample.txt") } + + before do + allow(Gitlab.config.pages).to receive(:enabled).and_return(true) + allow(Gitlab.config.pages).to receive(:artifacts_server).and_return(true) + end + + context "when the project is public" do + before do + visit(browse_url) + end + + it "shows correct content" do + link = first(".tree-item-file-external-link") + + expect(link[:target]).to eq("_blank") + expect(link[:rel]).to include("noopener").and include("noreferrer") + expect(page).to have_link("doc_sample.txt", href: file_project_job_artifacts_path(project, job, path: txt_entry.blob.path)) + .and have_selector(".js-artifact-tree-external-icon") + + page.within(".tree-table") do + expect(page).to have_content("..").and have_content("another-subdirectory") + end + + page.within(".repo-breadcrumb") do + expect(page).to have_content("other_artifacts_0.1.2") + end + end + end + + context "when the project is private" do + let!(:private_project) { create(:project, :private) } + let(:pipeline) { create(:ci_empty_pipeline, project: private_project) } + let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) } + let(:user) { create(:user) } + + before do + private_project.add_developer(user) + + sign_in(user) + + visit(browse_project_job_artifacts_path(private_project, job, "other_artifacts_0.1.2")) + end + + it { expect(page).to have_link("doc_sample.txt").and have_no_selector(".js-artifact-tree-external-icon") } + end + end +end diff --git a/spec/features/projects/artifacts/user_downloads_artifacts_spec.rb b/spec/features/projects/artifacts/user_downloads_artifacts_spec.rb new file mode 100644 index 00000000000..67ed2f18d76 --- /dev/null +++ b/spec/features/projects/artifacts/user_downloads_artifacts_spec.rb @@ -0,0 +1,44 @@ +require "spec_helper" + +describe "User downloads artifacts" do + set(:project) { create(:project, :public) } + set(:pipeline) { create(:ci_empty_pipeline, status: :success, project: project) } + set(:job) { create(:ci_build, :artifacts, :success, pipeline: pipeline) } + + shared_examples "downloading" do + it "downloads the zip" do + expect(page.response_headers["Content-Disposition"]).to eq(%Q{attachment; filename="#{job.artifacts_file.filename}"}) + expect(page.response_headers['Content-Transfer-Encoding']).to eq("binary") + expect(page.response_headers['Content-Type']).to eq("application/zip") + expect(page.source.b).to eq(job.artifacts_file.file.read.b) + end + end + + context "when downloading" do + before do + visit(url) + end + + context "via job id" do + set(:url) { download_project_job_artifacts_path(project, job) } + + it_behaves_like "downloading" + end + + context "via branch name and job name" do + set(:url) { latest_succeeded_project_artifacts_path(project, "#{pipeline.ref}/download", job: job.name) } + + it_behaves_like "downloading" + end + + context "via clicking the `Download` button" do + set(:url) { project_job_path(project, job) } + + before do + click_link("Download") + end + + it_behaves_like "downloading" + end + end +end diff --git a/spec/features/projects/clusters/gcp_spec.rb b/spec/features/projects/clusters/gcp_spec.rb index dfe8e02dce0..fe334b531f0 100644 --- a/spec/features/projects/clusters/gcp_spec.rb +++ b/spec/features/projects/clusters/gcp_spec.rb @@ -184,4 +184,44 @@ feature 'Gcp Cluster', :js do expect(page).to have_css('.signin-with-google') end end + + context 'when user has not dismissed GCP signup offer' do + before do + visit project_clusters_path(project) + end + + it 'user sees offer on cluster index page' do + expect(page).to have_css('.gcp-signup-offer') + end + + it 'user sees offer on cluster create page' do + click_link 'Add Kubernetes cluster' + + expect(page).to have_css('.gcp-signup-offer') + end + + it 'user sees offer on cluster GCP login page' do + click_link 'Add Kubernetes cluster' + click_link 'Create on Google Kubernetes Engine' + + expect(page).to have_css('.gcp-signup-offer') + end + end + + context 'when user has dismissed GCP signup offer' do + before do + visit project_clusters_path(project) + end + + it 'user does not see offer after dismissing' do + expect(page).to have_css('.gcp-signup-offer') + + find('.gcp-signup-offer .close').click + wait_for_requests + + click_link 'Add Kubernetes cluster' + + expect(page).not_to have_css('.gcp-signup-offer') + end + end end diff --git a/spec/features/projects/commits/user_browses_commits_spec.rb b/spec/features/projects/commits/user_browses_commits_spec.rb index b650c1f4197..35ed6620548 100644 --- a/spec/features/projects/commits/user_browses_commits_spec.rb +++ b/spec/features/projects/commits/user_browses_commits_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe 'User browses commits' do + include RepoHelpers + let(:user) { create(:user) } let(:project) { create(:project, :repository, namespace: user.namespace) } @@ -9,13 +11,68 @@ describe 'User browses commits' do sign_in(user) end + it 'renders commit' do + visit project_commit_path(project, sample_commit.id) + + expect(page).to have_content(sample_commit.message) + .and have_content("Showing #{sample_commit.files_changed_count} changed files") + .and have_content('Side-by-side') + end + + it 'fill commit sha when click new tag from commit page' do + visit project_commit_path(project, sample_commit.id) + click_link 'Tag' + + expect(page).to have_selector("input[value='#{sample_commit.id}']", visible: false) + end + + it 'renders inline diff button when click side-by-side diff button' do + visit project_commit_path(project, sample_commit.id) + find('#parallel-diff-btn').click + + expect(page).to have_content 'Inline' + end + + it 'renders breadcrumbs on specific commit path' do + visit project_commits_path(project, project.repository.root_ref + '/files/ruby/regex.rb', limit: 5) + + expect(page).to have_selector('ul.breadcrumb') + .and have_selector('ul.breadcrumb a', count: 4) + end + + it 'renders diff links to both the previous and current image' do + visit project_commit_path(project, sample_image_commit.id) + + links = page.all('.file-actions a') + expect(links[0]['href']).to match %r{blob/#{sample_image_commit.old_blob_id}} + expect(links[1]['href']).to match %r{blob/#{sample_image_commit.new_blob_id}} + end + + context 'when commit has ci status' do + let(:pipeline) { create(:ci_pipeline, project: project, sha: sample_commit.id) } + + before do + project.enable_ci + + create(:ci_build, pipeline: pipeline) + + allow_any_instance_of(Ci::Pipeline).to receive(:ci_yaml_file).and_return('') + end + + it 'renders commit ci info' do + visit project_commit_path(project, sample_commit.id) + + expect(page).to have_content "Pipeline ##{pipeline.id} pending" + end + end + context 'primary email' do it 'finds a commit by a primary email' do user = create(:user, email: 'dmitriy.zaporozhets@gmail.com') - visit(project_commit_path(project, RepoHelpers.sample_commit.id)) + visit(project_commit_path(project, sample_commit.id)) - check_author_link(RepoHelpers.sample_commit.author_email, user) + check_author_link(sample_commit.author_email, user) end end @@ -26,9 +83,9 @@ describe 'User browses commits' do create(:email, { user: user, email: 'dmitriy.zaporozhets@gmail.com' }) end - visit(project_commit_path(project, RepoHelpers.sample_commit.parent_id)) + visit(project_commit_path(project, sample_commit.parent_id)) - check_author_link(RepoHelpers.sample_commit.author_email, user) + check_author_link(sample_commit.author_email, user) end end @@ -44,6 +101,135 @@ describe 'User browses commits' do expect(find('.diff-file-changes', visible: false)).to have_content('No file name available') end end + + describe 'commits list' do + let(:visit_commits_page) do + visit project_commits_path(project, project.repository.root_ref, limit: 5) + end + + it 'searches commit', :js do + visit_commits_page + fill_in 'commits-search', with: 'submodules' + + expect(page).to have_content 'More submodules' + expect(page).not_to have_content 'Change some files' + end + + it 'renders commits atom feed' do + visit_commits_page + click_link('Commits feed') + + commit = project.repository.commit + + expect(response_headers['Content-Type']).to have_content("application/atom+xml") + expect(body).to have_selector('title', text: "#{project.name}:master commits") + .and have_selector('author email', text: commit.author_email) + .and have_selector('entry summary', text: commit.description[0..10].delete("\r\n")) + end + + context 'master branch' do + before do + visit_commits_page + end + + it 'renders project commits' do + commit = project.repository.commit + + expect(page).to have_content(project.name) + .and have_content(commit.message[0..20]) + .and have_content(commit.short_id) + end + + it 'does not render create merge request button' do + expect(page).not_to have_link 'Create merge request' + end + + context 'when click the compare tab' do + before do + click_link('Compare') + end + + it 'does not render create merge request button' do + expect(page).not_to have_link 'Create merge request' + end + end + end + + context 'feature branch' do + let(:visit_commits_page) do + visit project_commits_path(project, 'feature') + end + + context 'when project does not have open merge requests' do + before do + visit_commits_page + end + + it 'renders project commits' do + commit = project.repository.commit('0b4bc9a') + + expect(page).to have_content(project.name) + .and have_content(commit.message[0..12]) + .and have_content(commit.short_id) + end + + it 'renders create merge request button' do + expect(page).to have_link 'Create merge request' + end + + context 'when click the compare tab' do + before do + click_link('Compare') + end + + it 'renders create merge request button' do + expect(page).to have_link 'Create merge request' + end + end + end + + context 'when project have open merge request' do + let!(:merge_request) do + create( + :merge_request, + title: 'Feature', + source_project: project, + source_branch: 'feature', + target_branch: 'master', + author: project.users.first + ) + end + + before do + visit_commits_page + end + + it 'renders project commits' do + commit = project.repository.commit('0b4bc9a') + + expect(page).to have_content(project.name) + .and have_content(commit.message[0..12]) + .and have_content(commit.short_id) + end + + it 'renders button to the merge request' do + expect(page).not_to have_link 'Create merge request' + expect(page).to have_link 'View open merge request', href: project_merge_request_path(project, merge_request) + end + + context 'when click the compare tab' do + before do + click_link('Compare') + end + + it 'renders button to the merge request' do + expect(page).not_to have_link 'Create merge request' + expect(page).to have_link 'View open merge request', href: project_merge_request_path(project, merge_request) + end + end + end + end + end end private diff --git a/spec/features/projects/compare_spec.rb b/spec/features/projects/compare_spec.rb index 1fb22fd0e4c..7e863d9df32 100644 --- a/spec/features/projects/compare_spec.rb +++ b/spec/features/projects/compare_spec.rb @@ -7,16 +7,19 @@ describe "Compare", :js do before do project.add_master(user) sign_in user - visit project_compare_index_path(project, from: "master", to: "master") end describe "branches" do it "pre-populates fields" do + visit project_compare_index_path(project, from: "master", to: "master") + expect(find(".js-compare-from-dropdown .dropdown-toggle-text")).to have_content("master") expect(find(".js-compare-to-dropdown .dropdown-toggle-text")).to have_content("master") end it "compares branches" do + visit project_compare_index_path(project, from: "master", to: "master") + select_using_dropdown "from", "feature" expect(find(".js-compare-from-dropdown .dropdown-toggle-text")).to have_content("feature") @@ -26,9 +29,58 @@ describe "Compare", :js do click_button "Compare" expect(page).to have_content "Commits" + expect(page).to have_link 'Create merge request' + end + + it 'renders additions info when click unfold diff' do + visit project_compare_index_path(project) + + select_using_dropdown('from', RepoHelpers.sample_commit.parent_id, commit: true) + select_using_dropdown('to', RepoHelpers.sample_commit.id, commit: true) + + click_button 'Compare' + expect(page).to have_content 'Commits (1)' + expect(page).to have_content "Showing 2 changed files" + + diff = first('.js-unfold') + diff.click + wait_for_requests + + page.within diff.query_scope do + expect(first('.new_line').text).not_to have_content "..." + end + end + + context 'when project have an open merge request' do + let!(:merge_request) do + create( + :merge_request, + title: 'Feature', + source_project: project, + source_branch: 'feature', + target_branch: 'master', + author: project.users.first + ) + end + + it 'compares branches' do + visit project_compare_index_path(project) + + select_using_dropdown('from', 'master') + select_using_dropdown('to', 'feature') + + click_button 'Compare' + + expect(page).to have_content 'Commits (1)' + expect(page).to have_content 'Showing 1 changed file with 5 additions and 0 deletions' + expect(page).to have_link 'View open merge request', href: project_merge_request_path(project, merge_request) + expect(page).not_to have_link 'Create merge request' + end end it "filters branches" do + visit project_compare_index_path(project, from: "master", to: "master") + select_using_dropdown("from", "wip") find(".js-compare-from-dropdown .compare-dropdown-toggle").click @@ -39,6 +91,8 @@ describe "Compare", :js do describe "tags" do it "compares tags" do + visit project_compare_index_path(project, from: "master", to: "master") + select_using_dropdown "from", "v1.0.0" expect(find(".js-compare-from-dropdown .dropdown-toggle-text")).to have_content("v1.0.0") @@ -50,15 +104,20 @@ describe "Compare", :js do end end - def select_using_dropdown(dropdown_type, selection) + def select_using_dropdown(dropdown_type, selection, commit: false) dropdown = find(".js-compare-#{dropdown_type}-dropdown") dropdown.find(".compare-dropdown-toggle").click # find input before using to wait for the inputs visiblity dropdown.find('.dropdown-menu') dropdown.fill_in("Filter by Git revision", with: selection) wait_for_requests - # find before all to wait for the items visiblity - dropdown.find("a[data-ref=\"#{selection}\"]", match: :first) - dropdown.all("a[data-ref=\"#{selection}\"]").last.click + + if commit + dropdown.find('input[type="search"]').send_keys(:return) + else + # find before all to wait for the items visiblity + dropdown.find("a[data-ref=\"#{selection}\"]", match: :first) + dropdown.all("a[data-ref=\"#{selection}\"]").last.click + end end end diff --git a/spec/features/projects/deploy_keys_spec.rb b/spec/features/projects/deploy_keys_spec.rb index 886c56e7163..43a23c42f83 100644 --- a/spec/features/projects/deploy_keys_spec.rb +++ b/spec/features/projects/deploy_keys_spec.rb @@ -18,12 +18,12 @@ describe 'Project deploy keys', :js do visit project_settings_repository_path(project) page.within(find('.deploy-keys')) do - expect(page).to have_selector('.deploy-keys li', count: 1) + expect(page).to have_selector('.deploy-key', count: 1) - accept_confirm { find(:button, text: 'Remove').send_keys(:return) } + accept_confirm { find('.ic-remove').click() } expect(page).not_to have_selector('.fa-spinner', count: 0) - expect(page).to have_selector('.deploy-keys li', count: 0) + expect(page).to have_selector('.deploy-key', count: 0) end end end diff --git a/spec/features/projects/files/user_browses_files_spec.rb b/spec/features/projects/files/user_browses_files_spec.rb index 9c1f11f4c12..41f6c52fb8a 100644 --- a/spec/features/projects/files/user_browses_files_spec.rb +++ b/spec/features/projects/files/user_browses_files_spec.rb @@ -1,14 +1,12 @@ -require 'spec_helper' +require "spec_helper" -describe 'Projects > Files > User browses files' do +describe "User browses files" do let(:fork_message) do "You're not allowed to make changes to this project directly. "\ "A fork of this project has been created that you can make changes in, so you can submit a merge request." end - let(:project) { create(:project, :repository, name: 'Shop') } - let(:project2) { create(:project, :repository, name: 'Another Project', path: 'another-project') } - let(:project2_tree_path_root_ref) { project_tree_path(project2, project2.repository.root_ref) } - let(:tree_path_ref_6d39438) { project_tree_path(project, '6d39438') } + let(:project) { create(:project, :repository, name: "Shop") } + let(:project2) { create(:project, :repository, name: "Another Project", path: "another-project") } let(:tree_path_root_ref) { project_tree_path(project, project.repository.root_ref) } let(:user) { project.owner } @@ -16,57 +14,55 @@ describe 'Projects > Files > User browses files' do sign_in(user) end - it 'shows last commit for current directory' do + it "shows last commit for current directory" do visit(tree_path_root_ref) - click_link 'files' + click_link("files") - last_commit = project.repository.last_commit_for_path(project.default_branch, 'files') - page.within('.blob-commit-info') do - expect(page).to have_content last_commit.short_id - expect(page).to have_content last_commit.author_name + last_commit = project.repository.last_commit_for_path(project.default_branch, "files") + + page.within(".blob-commit-info") do + expect(page).to have_content(last_commit.short_id).and have_content(last_commit.author_name) end end - context 'when browsing the master branch' do + context "when browsing the master branch" do before do visit(tree_path_root_ref) end - it 'shows files from a repository' do - expect(page).to have_content('VERSION') - expect(page).to have_content('.gitignore') - expect(page).to have_content('LICENSE') + it "shows files from a repository" do + expect(page).to have_content("VERSION") + .and have_content(".gitignore") + .and have_content("LICENSE") end - it 'shows the "Browse Directory" link' do - click_link('files') - click_link('History') + it "shows the `Browse Directory` link" do + click_link("files") + click_link("History") - expect(page).to have_link('Browse Directory') - expect(page).not_to have_link('Browse Code') + expect(page).to have_link("Browse Directory").and have_no_link("Browse Code") end - it 'shows the "Browse File" link' do - page.within('.tree-table') do - click_link('README.md') + it "shows the `Browse File` link" do + page.within(".tree-table") do + click_link("README.md") end - click_link('History') - expect(page).to have_link('Browse File') - expect(page).not_to have_link('Browse Files') + click_link("History") + + expect(page).to have_link("Browse File").and have_no_link("Browse Files") end - it 'shows the "Browse Files" link' do - click_link('History') + it "shows the `Browse Files` link" do + click_link("History") - expect(page).to have_link('Browse Files') - expect(page).not_to have_link('Browse Directory') + expect(page).to have_link("Browse Files").and have_no_link("Browse Directory") end - it 'redirects to the permalink URL' do - click_link('.gitignore') - click_link('Permalink') + it "redirects to the permalink URL" do + click_link(".gitignore") + click_link("Permalink") permalink_path = project_blob_path(project, "#{project.repository.commit.sha}/.gitignore") @@ -74,80 +70,180 @@ describe 'Projects > Files > User browses files' do end end - context 'when browsing a specific ref' do + context "when browsing the `markdown` branch", :js do + context "when browsing the root" do + before do + visit(project_tree_path(project, "markdown")) + end + + it "shows correct files and links" do + # rubocop:disable Lint/Void + # Test the full URLs of links instead of relative paths by `have_link(text: "...", href: "...")`. + find("a", text: /^empty$/)["href"] == project_tree_url(project, "markdown") + find("a", text: /^#id$/)["href"] == project_tree_url(project, "markdown", anchor: "#id") + find("a", text: %r{^/#id$})["href"] == project_tree_url(project, "markdown", anchor: "#id") + find("a", text: /^README.md#id$/)["href"] == project_blob_url(project, "markdown/README.md", anchor: "#id") + find("a", text: %r{^d/README.md#id$})["href"] == project_blob_url(project, "d/markdown/README.md", anchor: "#id") + # rubocop:enable Lint/Void + + expect(current_path).to eq(project_tree_path(project, "markdown")) + expect(page).to have_content("README.md") + .and have_content("CHANGELOG") + .and have_content("Welcome to GitLab GitLab is a free project and repository management application") + .and have_link("GitLab API doc") + .and have_link("GitLab API website") + .and have_link("Rake tasks") + .and have_link("backup and restore procedure") + .and have_link("GitLab API doc directory") + .and have_link("Maintenance") + .and have_header_with_correct_id_and_link(2, "Application details", "application-details") + end + + it "shows correct content of file" do + click_link("GitLab API doc") + + expect(current_path).to eq(project_blob_path(project, "markdown/doc/api/README.md")) + expect(page).to have_content("All API requests require authentication") + .and have_content("Contents") + .and have_link("Users") + .and have_link("Rake tasks") + .and have_header_with_correct_id_and_link(1, "GitLab API", "gitlab-api") + + click_link("Users") + + expect(current_path).to eq(project_blob_path(project, "markdown/doc/api/users.md")) + expect(page).to have_content("Get a list of users.") + + page.go_back + + click_link("Rake tasks") + + expect(current_path).to eq(project_tree_path(project, "markdown/doc/raketasks")) + expect(page).to have_content("backup_restore.md").and have_content("maintenance.md") + + click_link("shop") + click_link("Maintenance") + + expect(current_path).to eq(project_blob_path(project, "markdown/doc/raketasks/maintenance.md")) + expect(page).to have_content("bundle exec rake gitlab:env:info RAILS_ENV=production") + + click_link("shop") + + page.within(".tree-table") do + click_link("README.md") + end + + page.go_back + + page.within(".tree-table") do + click_link("d") + end + + # rubocop:disable Lint/Void + # Test the full URLs of links instead of relative paths by `have_link(text: "...", href: "...")`. + find("a", text: /^empty$/)["href"] == project_tree_url(project, "markdown/d") + # rubocop:enable Lint/Void + + page.within(".tree-table") do + click_link("README.md") + end + + # rubocop:disable Lint/Void + # Test the full URLs of links instead of relative paths by `have_link(text: "...", href: "...")`. + find("a", text: /^empty$/)["href"] == project_blob_url(project, "markdown/d/README.md") + # rubocop:enable Lint/Void + end + + it "shows correct content of directory" do + click_link("GitLab API doc directory") + + expect(current_path).to eq(project_tree_path(project, "markdown/doc/api")) + expect(page).to have_content("README.md").and have_content("users.md") + + click_link("Users") + + expect(current_path).to eq(project_blob_path(project, "markdown/doc/api/users.md")) + expect(page).to have_content("List users").and have_content("Get a list of users.") + end + end + end + + context "when browsing a specific ref" do + let(:ref) { project_tree_path(project, "6d39438") } + before do - visit(tree_path_ref_6d39438) + visit(ref) end - it 'shows files from a repository for "6d39438"' do - expect(current_path).to eq(tree_path_ref_6d39438) - expect(page).to have_content('.gitignore') - expect(page).to have_content('LICENSE') + it "shows files from a repository for `6d39438`" do + expect(current_path).to eq(ref) + expect(page).to have_content(".gitignore").and have_content("LICENSE") end - it 'shows files from a repository with apostroph in its name', :js do - first('.js-project-refs-dropdown').click + it "shows files from a repository with apostroph in its name", :js do + first(".js-project-refs-dropdown").click - page.within('.project-refs-form') do + page.within(".project-refs-form") do click_link("'test'") end - expect(page).to have_selector('.dropdown-toggle-text', text: "'test'") + expect(page).to have_selector(".dropdown-toggle-text", text: "'test'") visit(project_tree_path(project, "'test'")) - expect(page).to have_css('.tree-commit-link', visible: true) - expect(page).not_to have_content('Loading commit data...') + expect(page).to have_css(".tree-commit-link").and have_no_content("Loading commit data...") end - it 'shows the code with a leading dot in the directory', :js do - first('.js-project-refs-dropdown').click + it "shows the code with a leading dot in the directory", :js do + first(".js-project-refs-dropdown").click - page.within('.project-refs-form') do - click_link('fix') + page.within(".project-refs-form") do + click_link("fix") end - visit(project_tree_path(project, 'fix/.testdir')) + visit(project_tree_path(project, "fix/.testdir")) - expect(page).to have_css('.tree-commit-link', visible: true) - expect(page).not_to have_content('Loading commit data...') + expect(page).to have_css(".tree-commit-link").and have_no_content("Loading commit data...") end - it 'does not show the permalink link' do - click_link('.gitignore') + it "does not show the permalink link" do + click_link(".gitignore") - expect(page).not_to have_link('permalink') + expect(page).not_to have_link("permalink") end end - context 'when browsing a file content' do + context "when browsing a file content" do before do visit(tree_path_root_ref) - click_link('.gitignore') + + click_link(".gitignore") end - it 'shows a file content', :js do - wait_for_requests - expect(page).to have_content('*.rbc') + it "shows a file content", :js do + expect(page).to have_content("*.rbc") end - it 'is possible to blame' do - click_link 'Blame' + it "is possible to blame" do + click_link("Blame") - expect(page).to have_content "*.rb" - expect(page).to have_content "Dmitriy Zaporozhets" - expect(page).to have_content "Initial commit" + expect(page).to have_content("*.rb") + .and have_content("Dmitriy Zaporozhets") + .and have_content("Initial commit") end end - context 'when browsing a raw file' do + context "when browsing a raw file" do before do - visit(project_blob_path(project, File.join(RepoHelpers.sample_commit.id, RepoHelpers.sample_blob.path))) + path = File.join(RepoHelpers.sample_commit.id, RepoHelpers.sample_blob.path) + + visit(project_blob_path(project, path)) end - it 'shows a raw file content' do - click_link('Open raw') - expect(source).to eq('') # Body is filled in by gitlab-workhorse + it "shows a raw file content" do + click_link("Open raw") + + expect(source).to eq("") # Body is filled in by gitlab-workhorse end end end diff --git a/spec/features/projects/import_export/import_file_spec.rb b/spec/features/projects/import_export/import_file_spec.rb index b25f5161748..60fe30bd898 100644 --- a/spec/features/projects/import_export/import_file_spec.rb +++ b/spec/features/projects/import_export/import_file_spec.rb @@ -46,7 +46,7 @@ feature 'Import/Export - project import integration test', :js do expect(project.merge_requests).not_to be_empty expect(project_hook_exists?(project)).to be true expect(wiki_exists?(project)).to be true - expect(project.import_status).to eq('finished') + expect(project.import_state.status).to eq('finished') end end diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb index a00db6dd161..9d1c4cbad8b 100644 --- a/spec/features/projects/jobs_spec.rb +++ b/spec/features/projects/jobs_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' require 'tempfile' -feature 'Jobs' do +feature 'Jobs', :clean_gitlab_redis_shared_state do let(:user) { create(:user) } let(:user_access_level) { :developer } let(:project) { create(:project, :repository) } @@ -282,7 +282,7 @@ feature 'Jobs' do it 'loads job trace' do expect(page).to have_content 'BUILD TRACE' - job.trace.write do |stream| + job.trace.write('a+b') do |stream| stream.append(' and more trace', 11) end @@ -593,44 +593,6 @@ feature 'Jobs' do end end - context 'storage form' do - let(:existing_file) { Tempfile.new('existing-trace-file').path } - - before do - job.run! - end - - context 'when job has trace in file', :js do - before do - allow_any_instance_of(Gitlab::Ci::Trace) - .to receive(:paths) - .and_return([existing_file]) - end - - it 'sends the right headers' do - requests = inspect_requests(inject_headers: { 'X-Sendfile-Type' => 'X-Sendfile' }) do - visit raw_project_job_path(project, job) - end - expect(requests.first.response_headers['Content-Type']).to eq('text/plain; charset=utf-8') - expect(requests.first.response_headers['X-Sendfile']).to eq(existing_file) - end - end - - context 'when job has trace in the database', :js do - before do - allow_any_instance_of(Gitlab::Ci::Trace) - .to receive(:paths) - .and_return([]) - - visit project_job_path(project, job) - end - - it 'sends the right headers' do - expect(page).not_to have_selector('.js-raw-link-controller') - end - end - end - context "when visiting old URL" do let(:raw_job_url) do raw_project_job_path(project, job) diff --git a/spec/features/projects/merge_requests/user_accepts_merge_request_spec.rb b/spec/features/projects/merge_requests/user_accepts_merge_request_spec.rb index c35ba2d7016..01aeed93947 100644 --- a/spec/features/projects/merge_requests/user_accepts_merge_request_spec.rb +++ b/spec/features/projects/merge_requests/user_accepts_merge_request_spec.rb @@ -10,6 +10,15 @@ describe 'User accepts a merge request', :js do sign_in(user) end + it 'presents merged merge request content' do + visit(merge_request_path(merge_request)) + + click_button('Merge') + + expect(page).to have_content("The changes were merged into #{merge_request.target_branch} with \ + #{merge_request.short_merge_commit_sha}") + end + context 'with removing the source branch' do before do visit(merge_request_path(merge_request)) diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb index 3c9ab583032..af2a9567a47 100644 --- a/spec/features/projects/pipelines/pipeline_spec.rb +++ b/spec/features/projects/pipelines/pipeline_spec.rb @@ -3,10 +3,11 @@ require 'spec_helper' describe 'Pipeline', :js do let(:project) { create(:project) } let(:user) { create(:user) } + let(:role) { :developer } before do sign_in(user) - project.add_developer(user) + project.add_role(user, role) end shared_context 'pipeline builds' do @@ -153,9 +154,10 @@ describe 'Pipeline', :js do end context 'page tabs' do - it 'shows Pipeline and Jobs tabs with link' do + it 'shows Pipeline, Jobs and Failed Jobs tabs with link' do expect(page).to have_link('Pipeline') expect(page).to have_link('Jobs') + expect(page).to have_link('Failed Jobs') end it 'shows counter in Jobs tab' do @@ -165,6 +167,16 @@ describe 'Pipeline', :js do it 'shows Pipeline tab as active' do expect(page).to have_css('.js-pipeline-tab-link .active') end + + context 'without permission to access builds' do + let(:project) { create(:project, :public, :repository, public_builds: false) } + let(:role) { :guest } + + it 'does not show failed jobs tab pane' do + expect(page).to have_link('Pipeline') + expect(page).not_to have_content('Failed Jobs') + end + end end context 'retrying jobs' do @@ -308,8 +320,7 @@ describe 'Pipeline', :js do end describe 'GET /:project/pipelines/:id/failures' do - let(:project) { create(:project, :repository) } - let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) } + let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: '1234') } let(:pipeline_failures_page) { failures_project_pipeline_path(project, pipeline) } let!(:failed_build) { create(:ci_build, :failed, pipeline: pipeline) } @@ -340,11 +351,39 @@ describe 'Pipeline', :js do visit pipeline_failures_page end - it 'includes failed jobs' do + it 'shows jobs tab pane as active' do + expect(page).to have_content('Failed Jobs') + expect(page).to have_css('#js-tab-failures.active') + end + + it 'lists failed builds' do + expect(page).to have_content(failed_build.name) + expect(page).to have_content(failed_build.stage) + end + + it 'does not show trace' do expect(page).to have_content('No job trace') end end + context 'without permission to access builds' do + let(:role) { :guest } + + before do + project.update(public_builds: false) + end + + context 'when accessing failed jobs page' do + before do + visit pipeline_failures_page + end + + it 'fails to access the page' do + expect(page).to have_content('Access Denied') + end + end + end + context 'without failures' do before do failed_build.update!(status: :success) diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index b9249e3b7e4..d66ad6ab7f6 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -388,9 +388,9 @@ describe 'Pipelines', :js do it 'should be possible to cancel pending build' do find('.js-builds-dropdown-button').click - find('a.js-ci-action-icon').click + find('.js-ci-action').click + wait_for_requests - expect(page).to have_content('canceled') expect(build.reload).to be_canceled end end @@ -407,7 +407,7 @@ describe 'Pipelines', :js do within('.js-builds-dropdown-list') do build_element = page.find('.mini-pipeline-graph-dropdown-item') - expect(build_element['data-title']).to eq('build - failed <br> (unknown failure)') + expect(build_element['data-original-title']).to eq('build - failed <br> (unknown failure)') end end end @@ -517,16 +517,31 @@ describe 'Pipelines', :js do end it 'creates a new pipeline' do - expect { click_on 'Run pipeline' } + expect { click_on 'Create pipeline' } .to change { Ci::Pipeline.count }.by(1) expect(Ci::Pipeline.last).to be_web end + + context 'when variables are specified' do + it 'creates a new pipeline with variables' do + page.within '.ci-variable-row-body' do + fill_in "Input variable key", with: "key_name" + fill_in "Input variable value", with: "value" + end + + expect { click_on 'Create pipeline' } + .to change { Ci::Pipeline.count }.by(1) + + expect(Ci::Pipeline.last.variables.map { |var| var.slice(:key, :secret_value) }) + .to eq [{ key: "key_name", secret_value: "value" }.with_indifferent_access] + end + end end context 'without gitlab-ci.yml' do before do - click_on 'Run pipeline' + click_on 'Create pipeline' end it { expect(page).to have_content('Missing .gitlab-ci.yml file') } @@ -539,7 +554,7 @@ describe 'Pipelines', :js do click_link 'master' end - expect { click_on 'Run pipeline' } + expect { click_on 'Create pipeline' } .to change { Ci::Pipeline.count }.by(1) end end @@ -557,7 +572,7 @@ describe 'Pipelines', :js do it 'has field to add a new pipeline' do expect(page).to have_selector('.js-branch-select') expect(find('.js-branch-select')).to have_content project.default_branch - expect(page).to have_content('Run on') + expect(page).to have_content('Create for') end end diff --git a/spec/features/projects/remote_mirror_spec.rb b/spec/features/projects/remote_mirror_spec.rb new file mode 100644 index 00000000000..81a6b613cc8 --- /dev/null +++ b/spec/features/projects/remote_mirror_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' + +feature 'Project remote mirror', :feature do + let(:project) { create(:project, :repository, :remote_mirror) } + let(:remote_mirror) { project.remote_mirrors.first } + let(:user) { create(:user) } + + describe 'On a project', :js do + before do + project.add_master(user) + sign_in user + end + + context 'when last_error is present but last_update_at is not' do + it 'renders error message without timstamp' do + remote_mirror.update_attributes(last_error: 'Some new error', last_update_at: nil) + + visit project_mirror_path(project) + + expect(page).to have_content('The remote repository failed to update.') + end + end + + context 'when last_error and last_update_at are present' do + it 'renders error message with timestamp' do + remote_mirror.update_attributes(last_error: 'Some new error', last_update_at: Time.now - 5.minutes) + + visit project_mirror_path(project) + + expect(page).to have_content('The remote repository failed to update 5 minutes ago.') + end + end + end +end diff --git a/spec/features/projects/settings/lfs_settings_spec.rb b/spec/features/projects/settings/lfs_settings_spec.rb index 0fd28a5681c..342be1d2a9d 100644 --- a/spec/features/projects/settings/lfs_settings_spec.rb +++ b/spec/features/projects/settings/lfs_settings_spec.rb @@ -1,21 +1,27 @@ require 'rails_helper' describe 'Projects > Settings > LFS settings' do - let(:admin) { create(:admin) } let(:project) { create(:project) } + let(:user) { create(:user) } + let(:role) { :master } context 'LFS enabled setting' do before do allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) - sign_in(admin) + sign_in(user) + project.add_role(user, role) end - it 'displays the correct elements', :js do - visit edit_project_path(project) + context 'for master' do + let(:role) { :master } - expect(page).to have_content('Git Large File Storage') - expect(page).to have_selector('input[name="project[lfs_enabled]"] + button', visible: true) + it 'displays the correct elements', :js do + visit edit_project_path(project) + + expect(page).to have_content('Git Large File Storage') + expect(page).to have_selector('input[name="project[lfs_enabled]"] + button', visible: true) + end end end end diff --git a/spec/features/projects/settings/repository_settings_spec.rb b/spec/features/projects/settings/repository_settings_spec.rb index e1dfe617691..08b40653764 100644 --- a/spec/features/projects/settings/repository_settings_spec.rb +++ b/spec/features/projects/settings/repository_settings_spec.rb @@ -54,7 +54,7 @@ describe 'Projects > Settings > Repository settings' do project.deploy_keys << private_deploy_key visit project_settings_repository_path(project) - find('li', text: private_deploy_key.title).click_link('Edit') + find('.deploy-key', text: private_deploy_key.title).find('.ic-pencil').click() fill_in 'deploy_key_title', with: 'updated_deploy_key' check 'deploy_key_deploy_keys_projects_attributes_0_can_push' @@ -71,11 +71,15 @@ describe 'Projects > Settings > Repository settings' do visit project_settings_repository_path(project) - find('li', text: private_deploy_key.title).click_link('Edit') + find('.js-deployKeys-tab-available_project_keys').click() + + find('.deploy-key', text: private_deploy_key.title).find('.ic-pencil').click() fill_in 'deploy_key_title', with: 'updated_deploy_key' click_button 'Save changes' + find('.js-deployKeys-tab-available_project_keys').click() + expect(page).to have_content('updated_deploy_key') end @@ -83,7 +87,7 @@ describe 'Projects > Settings > Repository settings' do project.deploy_keys << private_deploy_key visit project_settings_repository_path(project) - accept_confirm { find('li', text: private_deploy_key.title).click_button('Remove') } + accept_confirm { find('.deploy-key', text: private_deploy_key.title).find('.ic-remove').click() } expect(page).not_to have_content(private_deploy_key.title) end @@ -115,5 +119,20 @@ describe 'Projects > Settings > Repository settings' do expect(page).to have_content('Your new project deploy token has been created') end end + + context 'remote mirror settings' do + let(:user2) { create(:user) } + + before do + project.add_master(user2) + + visit project_settings_repository_path(project) + end + + it 'shows push mirror settings' do + expect(page).to have_selector('#project_remote_mirrors_attributes_0_enabled') + expect(page).to have_selector('#project_remote_mirrors_attributes_0_url') + end + end end end diff --git a/spec/features/projects/tree/create_directory_spec.rb b/spec/features/projects/tree/create_directory_spec.rb index b242e41df1c..3017048e506 100644 --- a/spec/features/projects/tree/create_directory_spec.rb +++ b/spec/features/projects/tree/create_directory_spec.rb @@ -44,12 +44,17 @@ feature 'Multi-file editor new directory', :js do wait_for_requests - click_button 'Stage all' + find('.js-ide-commit-mode').click + + find('.multi-file-commit-list-item').hover + first('.multi-file-discard-btn .btn').click fill_in('commit-message', with: 'commit message ide') click_button('Commit') + find('.js-ide-edit-mode').click + expect(page).to have_content('folder name') end end diff --git a/spec/features/projects/tree/create_file_spec.rb b/spec/features/projects/tree/create_file_spec.rb index 7d65456e049..56471c8e7aa 100644 --- a/spec/features/projects/tree/create_file_spec.rb +++ b/spec/features/projects/tree/create_file_spec.rb @@ -34,7 +34,10 @@ feature 'Multi-file editor new file', :js do wait_for_requests - click_button 'Stage all' + find('.js-ide-commit-mode').click + + find('.multi-file-commit-list-item').hover + first('.multi-file-discard-btn .btn').click fill_in('commit-message', with: 'commit message ide') diff --git a/spec/features/projects/wiki/markdown_preview_spec.rb b/spec/features/projects/wiki/markdown_preview_spec.rb index 006c15d60c5..6586ccaa400 100644 --- a/spec/features/projects/wiki/markdown_preview_spec.rb +++ b/spec/features/projects/wiki/markdown_preview_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' feature 'Projects > Wiki > User previews markdown changes', :js do let(:user) { create(:user) } - let(:project) { create(:project, namespace: user.namespace) } + let(:project) { create(:project, :wiki_repo, namespace: user.namespace) } let(:wiki_content) do <<-HEREDOC [regular link](regular) diff --git a/spec/features/projects/wiki/shortcuts_spec.rb b/spec/features/projects/wiki/shortcuts_spec.rb index f70d1e710dd..6178361082e 100644 --- a/spec/features/projects/wiki/shortcuts_spec.rb +++ b/spec/features/projects/wiki/shortcuts_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' feature 'Wiki shortcuts', :js do let(:user) { create(:user) } - let(:project) { create(:project, namespace: user.namespace) } + let(:project) { create(:project, :wiki_repo, namespace: user.namespace) } let(:wiki_page) { create(:wiki_page, wiki: project.wiki, attrs: { title: 'home', content: 'Home page' }) } before do diff --git a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb index 4a9d1cb87e1..9989e1ffda7 100644 --- a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb +++ b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb @@ -1,6 +1,6 @@ -require 'spec_helper' +require "spec_helper" -describe 'User creates wiki page' do +describe "User creates wiki page" do let(:user) { create(:user) } before do @@ -10,67 +10,104 @@ describe 'User creates wiki page' do visit(project_wikis_path(project)) end - context 'when wiki is empty' do - context 'in a user namespace' do - let(:project) { create(:project, namespace: user.namespace) } + context "when wiki is empty" do + context "in a user namespace" do + let(:project) { create(:project, :wiki_repo, namespace: user.namespace) } - it 'shows validation error message' do - page.within('.wiki-form') do - fill_in(:wiki_content, with: '') - click_on('Create page') + it "shows validation error message" do + page.within(".wiki-form") do + fill_in(:wiki_content, with: "") + + click_on("Create page") end - expect(page).to have_content('The form contains the following error:') - expect(page).to have_content("Content can't be blank") + expect(page).to have_content("The form contains the following error:").and have_content("Content can't be blank") + + page.within(".wiki-form") do + fill_in(:wiki_content, with: "[link test](test)") - page.within('.wiki-form') do - fill_in(:wiki_content, with: '[link test](test)') - click_on('Create page') + click_on("Create page") end - expect(page).to have_content('Home') - expect(page).to have_content('link test') + expect(page).to have_content("Home").and have_content("link test") - click_link('link test') + click_link("link test") - expect(page).to have_content('Create Page') + expect(page).to have_content("Create Page") end - it 'shows non-escaped link in the pages list', :js do - click_link('New page') + it "shows non-escaped link in the pages list", :js do + click_link("New page") - page.within('#modal-new-wiki') do - fill_in(:new_wiki_path, with: 'one/two/three-test') - click_on('Create page') + page.within("#modal-new-wiki") do + fill_in(:new_wiki_path, with: "one/two/three-test") + + click_on("Create page") end - page.within('.wiki-form') do - fill_in(:wiki_content, with: 'wiki content') - click_on('Create page') + page.within(".wiki-form") do + fill_in(:wiki_content, with: "wiki content") + + click_on("Create page") end - expect(current_path).to include('one/two/three-test') + expect(current_path).to include("one/two/three-test") expect(page).to have_xpath("//a[@href='/#{project.full_path}/wikis/one/two/three-test']") end - it 'has "Create home" as a commit message' do - expect(page).to have_field('wiki[message]', with: 'Create home') + it "has `Create home` as a commit message" do + expect(page).to have_field("wiki[message]", with: "Create home") end - it 'creates a page from the home page' do - fill_in(:wiki_content, with: 'My awesome wiki!') + it "creates a page from the home page" do + fill_in(:wiki_content, with: "[test](test)\n[GitLab API doc](api)\n[Rake tasks](raketasks)\n# Wiki header\n") + fill_in(:wiki_message, with: "Adding links to wiki") + + page.within(".wiki-form") do + click_button("Create page") + end + + expect(current_path).to eq(project_wiki_path(project, "home")) + expect(page).to have_content("test GitLab API doc Rake tasks Wiki header") + .and have_content("Home") + .and have_content("Last edited by #{user.name}") + .and have_header_with_correct_id_and_link(1, "Wiki header", "wiki-header") + + click_link("test") - page.within('.wiki-form') do - click_button('Create page') + expect(current_path).to eq(project_wiki_path(project, "test")) + + page.within(:css, ".nav-text") do + expect(page).to have_content("Test").and have_content("Create Page") + end + + click_link("Home") + + expect(current_path).to eq(project_wiki_path(project, "home")) + + click_link("GitLab API") + + expect(current_path).to eq(project_wiki_path(project, "api")) + + page.within(:css, ".nav-text") do + expect(page).to have_content("Create").and have_content("Api") end - expect(page).to have_content('Home') - expect(page).to have_content("Last edited by #{user.name}") - expect(page).to have_content('My awesome wiki!') + click_link("Home") + + expect(current_path).to eq(project_wiki_path(project, "home")) + + click_link("Rake tasks") + + expect(current_path).to eq(project_wiki_path(project, "raketasks")) + + page.within(:css, ".nav-text") do + expect(page).to have_content("Create").and have_content("Rake") + end end - it 'creates ASCII wiki with LaTeX blocks', :js do - stub_application_setting(plantuml_url: 'http://localhost', plantuml_enabled: true) + it "creates ASCII wiki with LaTeX blocks", :js do + stub_application_setting(plantuml_url: "http://localhost", plantuml_enabled: true) ascii_content = <<~MD :stem: latexmath @@ -90,153 +127,164 @@ describe 'User creates wiki page' do stem:[2+2] is 4 MD - find('#wiki_format option[value=asciidoc]').select_option + find("#wiki_format option[value=asciidoc]").select_option + fill_in(:wiki_content, with: ascii_content) - page.within('.wiki-form') do - click_button('Create page') + page.within(".wiki-form") do + click_button("Create page") end - page.within '.wiki' do - expect(page).to have_selector('.katex', count: 3) - expect(page).to have_content('2+2 is 4') + page.within ".wiki" do + expect(page).to have_selector(".katex", count: 3).and have_content("2+2 is 4") end end end - context 'in a group namespace', :js do - let(:project) { create(:project, namespace: create(:group, :public)) } + context "in a group namespace", :js do + let(:project) { create(:project, :wiki_repo, namespace: create(:group, :public)) } - it 'has "Create home" as a commit message' do - expect(page).to have_field('wiki[message]', with: 'Create home') + it "has `Create home` as a commit message" do + expect(page).to have_field("wiki[message]", with: "Create home") end - it 'creates a page from from the home page' do - page.within('.wiki-form') do - fill_in(:wiki_content, with: 'My awesome wiki!') - click_button('Create page') + it "creates a page from from the home page" do + page.within(".wiki-form") do + fill_in(:wiki_content, with: "My awesome wiki!") + + click_button("Create page") end - expect(page).to have_content('Home') - expect(page).to have_content("Last edited by #{user.name}") - expect(page).to have_content('My awesome wiki!') + expect(page).to have_content("Home") + .and have_content("Last edited by #{user.name}") + .and have_content("My awesome wiki!") end end end - context 'when wiki is not empty', :js do + context "when wiki is not empty", :js do before do - create(:wiki_page, wiki: create(:project, namespace: user.namespace).wiki, attrs: { title: 'home', content: 'Home page' }) + create(:wiki_page, wiki: create(:project, :wiki_repo, namespace: user.namespace).wiki, attrs: { title: "home", content: "Home page" }) end - context 'in a user namespace' do - let(:project) { create(:project, namespace: user.namespace) } + context "in a user namespace" do + let(:project) { create(:project, :wiki_repo, namespace: user.namespace) } - context 'via the "new wiki page" page' do - it 'creates a page with a single word' do - click_link('New page') + context "via the `new wiki page` page" do + it "creates a page with a single word" do + click_link("New page") - page.within('#modal-new-wiki') do - fill_in(:new_wiki_path, with: 'foo') - click_button('Create page') + page.within("#modal-new-wiki") do + fill_in(:new_wiki_path, with: "foo") + + click_button("Create page") end # Commit message field should have correct value. - expect(page).to have_field('wiki[message]', with: 'Create foo') + expect(page).to have_field("wiki[message]", with: "Create foo") + + page.within(".wiki-form") do + fill_in(:wiki_content, with: "My awesome wiki!") - page.within('.wiki-form') do - fill_in(:wiki_content, with: 'My awesome wiki!') - click_button('Create page') + click_button("Create page") end - expect(page).to have_content('Foo') - expect(page).to have_content("Last edited by #{user.name}") - expect(page).to have_content('My awesome wiki!') + expect(page).to have_content("Foo") + .and have_content("Last edited by #{user.name}") + .and have_content("My awesome wiki!") end - it 'creates a page with spaces in the name' do - click_link('New page') + it "creates a page with spaces in the name" do + click_link("New page") - page.within('#modal-new-wiki') do - fill_in(:new_wiki_path, with: 'Spaces in the name') - click_button('Create page') + page.within("#modal-new-wiki") do + fill_in(:new_wiki_path, with: "Spaces in the name") + + click_button("Create page") end # Commit message field should have correct value. - expect(page).to have_field('wiki[message]', with: 'Create spaces in the name') + expect(page).to have_field("wiki[message]", with: "Create spaces in the name") + + page.within(".wiki-form") do + fill_in(:wiki_content, with: "My awesome wiki!") - page.within('.wiki-form') do - fill_in(:wiki_content, with: 'My awesome wiki!') - click_button('Create page') + click_button("Create page") end - expect(page).to have_content('Spaces in the name') - expect(page).to have_content("Last edited by #{user.name}") - expect(page).to have_content('My awesome wiki!') + expect(page).to have_content("Spaces in the name") + .and have_content("Last edited by #{user.name}") + .and have_content("My awesome wiki!") end - it 'creates a page with hyphens in the name' do - click_link('New page') + it "creates a page with hyphens in the name" do + click_link("New page") - page.within('#modal-new-wiki') do - fill_in(:new_wiki_path, with: 'hyphens-in-the-name') - click_button('Create page') + page.within("#modal-new-wiki") do + fill_in(:new_wiki_path, with: "hyphens-in-the-name") + + click_button("Create page") end # Commit message field should have correct value. - expect(page).to have_field('wiki[message]', with: 'Create hyphens in the name') + expect(page).to have_field("wiki[message]", with: "Create hyphens in the name") + + page.within(".wiki-form") do + fill_in(:wiki_content, with: "My awesome wiki!") - page.within('.wiki-form') do - fill_in(:wiki_content, with: 'My awesome wiki!') - click_button('Create page') + click_button("Create page") end - expect(page).to have_content('Hyphens in the name') - expect(page).to have_content("Last edited by #{user.name}") - expect(page).to have_content('My awesome wiki!') + expect(page).to have_content("Hyphens in the name") + .and have_content("Last edited by #{user.name}") + .and have_content("My awesome wiki!") end end - it 'shows the autocompletion dropdown' do - click_link('New page') + it "shows the autocompletion dropdown" do + click_link("New page") - page.within('#modal-new-wiki') do - fill_in(:new_wiki_path, with: 'test-autocomplete') - click_button('Create page') + page.within("#modal-new-wiki") do + fill_in(:new_wiki_path, with: "test-autocomplete") + + click_button("Create page") end - page.within('.wiki-form') do - find('#wiki_content').native.send_keys('') - fill_in(:wiki_content, with: '@') + page.within(".wiki-form") do + find("#wiki_content").native.send_keys("") + + fill_in(:wiki_content, with: "@") end - expect(page).to have_selector('.atwho-view') + expect(page).to have_selector(".atwho-view") end end - context 'in a group namespace' do - let(:project) { create(:project, namespace: create(:group, :public)) } + context "in a group namespace" do + let(:project) { create(:project, :wiki_repo, namespace: create(:group, :public)) } - context 'via the "new wiki page" page' do - it 'creates a page' do - click_link('New page') + context "via the `new wiki page` page" do + it "creates a page" do + click_link("New page") - page.within('#modal-new-wiki') do - fill_in(:new_wiki_path, with: 'foo') - click_button('Create page') + page.within("#modal-new-wiki") do + fill_in(:new_wiki_path, with: "foo") + + click_button("Create page") end # Commit message field should have correct value. - expect(page).to have_field('wiki[message]', with: 'Create foo') + expect(page).to have_field("wiki[message]", with: "Create foo") + + page.within(".wiki-form") do + fill_in(:wiki_content, with: "My awesome wiki!") - page.within('.wiki-form') do - fill_in(:wiki_content, with: 'My awesome wiki!') - click_button('Create page') + click_button("Create page") end - expect(page).to have_content('Foo') - expect(page).to have_content("Last edited by #{user.name}") - expect(page).to have_content('My awesome wiki!') + expect(page).to have_content("Foo") + .and have_content("Last edited by #{user.name}") + .and have_content("My awesome wiki!") end end end diff --git a/spec/features/projects/wiki/user_deletes_wiki_page_spec.rb b/spec/features/projects/wiki/user_deletes_wiki_page_spec.rb index 605e332196b..ab9420fc38f 100644 --- a/spec/features/projects/wiki/user_deletes_wiki_page_spec.rb +++ b/spec/features/projects/wiki/user_deletes_wiki_page_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' feature 'User deletes wiki page' do let(:user) { create(:user) } - let(:project) { create(:project, namespace: user.namespace) } + let(:project) { create(:project, :wiki_repo, namespace: user.namespace) } let(:wiki_page) { create(:wiki_page, wiki: project.wiki) } before do diff --git a/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb b/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb index 37a118c34ab..823399ac3c3 100644 --- a/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb +++ b/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe 'Projects > Wiki > User views Git access wiki page' do let(:user) { create(:user) } - let(:project) { create(:project, :public) } + let(:project) { create(:project, :wiki_repo, :public) } let(:wiki_page) { create(:wiki_page, wiki: project.wiki, attrs: { title: 'home', content: '[some link](other-page)' }) } before do diff --git a/spec/features/projects/wiki/user_updates_wiki_page_spec.rb b/spec/features/projects/wiki/user_updates_wiki_page_spec.rb index ef1bb712846..e019e3ce5a5 100644 --- a/spec/features/projects/wiki/user_updates_wiki_page_spec.rb +++ b/spec/features/projects/wiki/user_updates_wiki_page_spec.rb @@ -14,7 +14,7 @@ describe 'User updates wiki page' do end context 'in a user namespace' do - let(:project) { create(:project, namespace: user.namespace) } + let(:project) { create(:project, :wiki_repo, namespace: user.namespace) } it 'redirects back to the home edit page' do page.within(:css, '.wiki-form .form-actions') do @@ -66,7 +66,7 @@ describe 'User updates wiki page' do end context 'in a user namespace' do - let(:project) { create(:project, namespace: user.namespace) } + let(:project) { create(:project, :wiki_repo, namespace: user.namespace) } it 'updates a page' do click_link('Edit') @@ -134,7 +134,7 @@ describe 'User updates wiki page' do end context 'in a group namespace' do - let(:project) { create(:project, namespace: create(:group, :public)) } + let(:project) { create(:project, :wiki_repo, namespace: create(:group, :public)) } it 'updates a page' do click_link('Edit') @@ -154,7 +154,7 @@ describe 'User updates wiki page' do end context 'when the page is in a subdir' do - let!(:project) { create(:project, namespace: user.namespace) } + let!(:project) { create(:project, :wiki_repo, namespace: user.namespace) } let(:project_wiki) { create(:project_wiki, project: project, user: project.creator) } let(:page_name) { 'page_name' } let(:page_dir) { "foo/bar/#{page_name}" } diff --git a/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb b/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb index 2682b62fa04..92b50169476 100644 --- a/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb +++ b/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb @@ -11,6 +11,7 @@ describe 'Projects > Wiki > User views wiki in project page' do context 'when repository is disabled for project' do let(:project) do create(:project, + :wiki_repo, :repository_disabled, :merge_requests_disabled, :builds_disabled) diff --git a/spec/features/projects/wiki/user_views_wiki_page_spec.rb b/spec/features/projects/wiki/user_views_wiki_page_spec.rb index 306e382119a..6661714222a 100644 --- a/spec/features/projects/wiki/user_views_wiki_page_spec.rb +++ b/spec/features/projects/wiki/user_views_wiki_page_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe 'User views a wiki page' do shared_examples 'wiki page user view' do let(:user) { create(:user) } - let(:project) { create(:project, namespace: user.namespace) } + let(:project) { create(:project, :wiki_repo, namespace: user.namespace) } let(:wiki_page) do create(:wiki_page, wiki: project.wiki, diff --git a/spec/features/raven_js_spec.rb b/spec/features/raven_js_spec.rb index 74890c86047..a9e815eaf4f 100644 --- a/spec/features/raven_js_spec.rb +++ b/spec/features/raven_js_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' feature 'RavenJS' do - let(:raven_path) { '/raven.bundle.js' } + let(:raven_path) { '/raven.chunk.js' } it 'should not load raven if sentry is disabled' do visit new_user_session_path diff --git a/spec/features/runners_spec.rb b/spec/features/runners_spec.rb index df65c2d2f83..e0cd963fe39 100644 --- a/spec/features/runners_spec.rb +++ b/spec/features/runners_spec.rb @@ -15,7 +15,7 @@ feature 'Runners' do end scenario 'user can see a button to install runners on kubernetes clusters' do - visit runners_path(project) + visit project_runners_path(project) expect(page).to have_link('Install Runner on Kubernetes', href: project_clusters_path(project)) end @@ -36,7 +36,7 @@ feature 'Runners' do end scenario 'user sees the specific runner' do - visit runners_path(project) + visit project_runners_path(project) within '.activated-specific-runners' do expect(page).to have_content(specific_runner.display_name) @@ -48,7 +48,7 @@ feature 'Runners' do end scenario 'user can pause and resume the specific runner' do - visit runners_path(project) + visit project_runners_path(project) within '.activated-specific-runners' do expect(page).to have_content('Pause') @@ -68,7 +68,7 @@ feature 'Runners' do end scenario 'user removes an activated specific runner if this is last project for that runners' do - visit runners_path(project) + visit project_runners_path(project) within '.activated-specific-runners' do click_on 'Remove Runner' @@ -78,7 +78,7 @@ feature 'Runners' do end scenario 'user edits the runner to be protected' do - visit runners_path(project) + visit project_runners_path(project) within '.activated-specific-runners' do first('.edit-runner > a').click @@ -98,7 +98,7 @@ feature 'Runners' do end scenario 'user edits runner not to run untagged jobs' do - visit runners_path(project) + visit project_runners_path(project) within '.activated-specific-runners' do first('.edit-runner > a').click @@ -117,7 +117,7 @@ feature 'Runners' do given!(:shared_runner) { create(:ci_runner, :shared) } scenario 'user sees CI/CD setting page' do - visit runners_path(project) + visit project_runners_path(project) expect(page.find('.available-shared-runners')).to have_content(shared_runner.display_name) end @@ -134,7 +134,7 @@ feature 'Runners' do end scenario 'user enables and disables a specific runner' do - visit runners_path(project) + visit project_runners_path(project) within '.available-specific-runners' do click_on 'Enable for this project' @@ -159,7 +159,7 @@ feature 'Runners' do end scenario 'user sees shared runners description' do - visit runners_path(project) + visit project_runners_path(project) expect(page.find('.shared-runners-description')).to have_content(shared_runners_html) end @@ -174,11 +174,185 @@ feature 'Runners' do end scenario 'user enables shared runners' do - visit runners_path(project) + visit project_runners_path(project) click_on 'Enable shared Runners' expect(page.find('.shared-runners-description')).to have_content('Disable shared Runners') end end + + context 'group runners in project settings' do + background do + project.add_master(user) + end + + given(:group) { create :group } + + context 'as project and group master' do + background do + group.add_master(user) + end + + context 'project with a group but no group runner' do + given(:project) { create :project, group: group } + + scenario 'group runners are not available' do + visit project_runners_path(project) + + expect(page).to have_content 'This group does not provide any group Runners yet' + + expect(page).to have_content 'Group masters can register group runners in the Group CI/CD settings' + expect(page).not_to have_content 'Ask your group master to setup a group Runner' + end + end + end + + context 'as project master' do + context 'project without a group' do + given(:project) { create :project } + + scenario 'group runners are not available' do + visit project_runners_path(project) + + expect(page).to have_content 'This project does not belong to a group and can therefore not make use of group Runners.' + end + end + + context 'project with a group but no group runner' do + given(:group) { create :group } + given(:project) { create :project, group: group } + + scenario 'group runners are not available' do + visit project_runners_path(project) + + expect(page).to have_content 'This group does not provide any group Runners yet.' + + expect(page).not_to have_content 'Group masters can register group runners in the Group CI/CD settings' + expect(page).to have_content 'Ask your group master to setup a group Runner.' + end + end + + context 'project with a group and a group runner' do + given(:group) { create :group } + given(:project) { create :project, group: group } + given!(:ci_runner) { create :ci_runner, groups: [group], description: 'group-runner' } + + scenario 'group runners are available' do + visit project_runners_path(project) + + expect(page).to have_content 'Available group Runners : 1' + expect(page).to have_content 'group-runner' + end + + scenario 'group runners may be disabled for a project' do + visit project_runners_path(project) + + click_on 'Disable group Runners' + + expect(page).to have_content 'Enable group Runners' + expect(project.reload.group_runners_enabled).to be false + + click_on 'Enable group Runners' + + expect(page).to have_content 'Disable group Runners' + expect(project.reload.group_runners_enabled).to be true + end + end + end + end + + context 'group runners in group settings' do + given(:group) { create :group } + background do + group.add_master(user) + end + + context 'group with no runners' do + scenario 'there are no runners displayed' do + visit group_settings_ci_cd_path(group) + + expect(page).to have_content 'This group does not provide any group Runners yet' + end + end + + context 'group with a runner' do + let!(:runner) { create :ci_runner, groups: [group], description: 'group-runner' } + + scenario 'the runner is visible' do + visit group_settings_ci_cd_path(group) + + expect(page).not_to have_content 'This group does not provide any group Runners yet' + expect(page).to have_content 'Available group Runners : 1' + expect(page).to have_content 'group-runner' + end + + scenario 'user can pause and resume the group runner' do + visit group_settings_ci_cd_path(group) + + expect(page).to have_content('Pause') + expect(page).not_to have_content('Resume') + + click_on 'Pause' + + expect(page).not_to have_content('Pause') + expect(page).to have_content('Resume') + + click_on 'Resume' + + expect(page).to have_content('Pause') + expect(page).not_to have_content('Resume') + end + + scenario 'user can view runner details' do + visit group_settings_ci_cd_path(group) + + expect(page).to have_content(runner.display_name) + + click_on runner.short_sha + + expect(page).to have_content(runner.platform) + end + + scenario 'user can remove a group runner' do + visit group_settings_ci_cd_path(group) + + click_on 'Remove Runner' + + expect(page).not_to have_content(runner.display_name) + end + + scenario 'user edits the runner to be protected' do + visit group_settings_ci_cd_path(group) + + first('.edit-runner > a').click + + expect(page.find_field('runner[access_level]')).not_to be_checked + + check 'runner_access_level' + click_button 'Save changes' + + expect(page).to have_content 'Protected Yes' + end + + context 'when a runner has a tag' do + background do + runner.update(tag_list: ['tag']) + end + + scenario 'user edits runner not to run untagged jobs' do + visit group_settings_ci_cd_path(group) + + first('.edit-runner > a').click + + expect(page.find_field('runner[run_untagged]')).to be_checked + + uncheck 'runner_run_untagged' + click_button 'Save changes' + + expect(page).to have_content 'Can run untagged jobs No' + end + end + end + end end diff --git a/spec/features/search/user_searches_for_wiki_pages_spec.rb b/spec/features/search/user_searches_for_wiki_pages_spec.rb index 7934779058f..5098fb49ee1 100644 --- a/spec/features/search/user_searches_for_wiki_pages_spec.rb +++ b/spec/features/search/user_searches_for_wiki_pages_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe 'User searches for wiki pages', :js do let(:user) { create(:user) } - let(:project) { create(:project, namespace: user.namespace) } + let(:project) { create(:project, :wiki_repo, namespace: user.namespace) } let!(:wiki_page) { create(:wiki_page, wiki: project.wiki, attrs: { title: 'test_wiki', content: 'Some Wiki content' }) } before do diff --git a/spec/features/users/active_sessions_spec.rb b/spec/features/users/active_sessions_spec.rb new file mode 100644 index 00000000000..631d7e3bced --- /dev/null +++ b/spec/features/users/active_sessions_spec.rb @@ -0,0 +1,69 @@ +require 'spec_helper' + +feature 'Active user sessions', :clean_gitlab_redis_shared_state do + scenario 'Successful login adds a new active user login' do + now = Time.zone.parse('2018-03-12 09:06') + Timecop.freeze(now) do + user = create(:user) + gitlab_sign_in(user) + expect(current_path).to eq root_path + + sessions = ActiveSession.list(user) + expect(sessions.count).to eq 1 + + # refresh the current page updates the updated_at + Timecop.freeze(now + 1.minute) do + visit current_path + + sessions = ActiveSession.list(user) + expect(sessions.first).to have_attributes( + created_at: Time.zone.parse('2018-03-12 09:06'), + updated_at: Time.zone.parse('2018-03-12 09:07') + ) + end + end + end + + scenario 'Successful login cleans up obsolete entries' do + user = create(:user) + + Gitlab::Redis::SharedState.with do |redis| + redis.sadd("session:lookup:user:gitlab:#{user.id}", '59822c7d9fcdfa03725eff41782ad97d') + end + + gitlab_sign_in(user) + + Gitlab::Redis::SharedState.with do |redis| + expect(redis.smembers("session:lookup:user:gitlab:#{user.id}")).not_to include '59822c7d9fcdfa03725eff41782ad97d' + end + end + + scenario 'Sessionless login does not clean up obsolete entries' do + user = create(:user) + personal_access_token = create(:personal_access_token, user: user) + + Gitlab::Redis::SharedState.with do |redis| + redis.sadd("session:lookup:user:gitlab:#{user.id}", '59822c7d9fcdfa03725eff41782ad97d') + end + + visit user_path(user, :atom, private_token: personal_access_token.token) + expect(page.status_code).to eq 200 + + Gitlab::Redis::SharedState.with do |redis| + expect(redis.smembers("session:lookup:user:gitlab:#{user.id}")).to include '59822c7d9fcdfa03725eff41782ad97d' + end + end + + scenario 'Logout deletes the active user login' do + user = create(:user) + gitlab_sign_in(user) + expect(current_path).to eq root_path + + expect(ActiveSession.list(user).count).to eq 1 + + gitlab_sign_out + expect(current_path).to eq new_user_session_path + + expect(ActiveSession.list(user)).to be_empty + end +end diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb index 9e10bfb2adc..94a2b289e64 100644 --- a/spec/features/users/login_spec.rb +++ b/spec/features/users/login_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' feature 'Login' do + include TermsHelper + scenario 'Successful user signin invalidates password reset token' do user = create(:user) @@ -399,4 +401,41 @@ feature 'Login' do expect(page).to have_selector('.tab-pane.active', count: 1) end end + + context 'when terms are enforced' do + let(:user) { create(:user) } + + before do + enforce_terms + end + + it 'asks to accept the terms on first login' do + visit new_user_session_path + + fill_in 'user_login', with: user.email + fill_in 'user_password', with: '12345678' + + click_button 'Sign in' + + expect_to_be_on_terms_page + + click_button 'Accept terms' + + expect(current_path).to eq(root_path) + expect(page).not_to have_content('You are already signed in.') + end + + it 'does not ask for terms when the user already accepted them' do + accept_terms(user) + + visit new_user_session_path + + fill_in 'user_login', with: user.email + fill_in 'user_password', with: '12345678' + + click_button 'Sign in' + + expect(current_path).to eq(root_path) + end + end end diff --git a/spec/features/users/signup_spec.rb b/spec/features/users/signup_spec.rb index 5d539f0ccbe..b5bd5c505f2 100644 --- a/spec/features/users/signup_spec.rb +++ b/spec/features/users/signup_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe 'Signup' do + include TermsHelper + let(:new_user) { build_stubbed(:user) } describe 'username validation', :js do @@ -132,4 +134,27 @@ describe 'Signup' do expect(page.body).not_to match(/#{new_user.password}/) end end + + context 'when terms are enforced' do + before do + enforce_terms + end + + it 'asks the user to accept terms before going to the dashboard' do + visit root_path + + fill_in 'new_user_name', with: new_user.name + fill_in 'new_user_username', with: new_user.username + fill_in 'new_user_email', with: new_user.email + fill_in 'new_user_email_confirmation', with: new_user.email + fill_in 'new_user_password', with: new_user.password + click_button "Register" + + expect_to_be_on_terms_page + + click_button 'Accept terms' + + expect(current_path).to eq dashboard_projects_path + end + end end diff --git a/spec/features/users/terms_spec.rb b/spec/features/users/terms_spec.rb new file mode 100644 index 00000000000..bf6b5fa3d6a --- /dev/null +++ b/spec/features/users/terms_spec.rb @@ -0,0 +1,84 @@ +require 'spec_helper' + +describe 'Users > Terms' do + include TermsHelper + + let(:user) { create(:user) } + let!(:term) { create(:term, terms: 'By accepting, you promise to be nice!') } + + before do + stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') + sign_in(user) + end + + it 'shows the terms' do + visit terms_path + + expect(page).to have_content('By accepting, you promise to be nice!') + end + + context 'declining the terms' do + it 'returns the user to the app' do + visit terms_path + + click_button 'Decline and sign out' + + expect(page).not_to have_content(term.terms) + expect(user.reload.terms_accepted?).to be(false) + end + end + + context 'accepting the terms' do + it 'returns the user to the app' do + visit terms_path + + click_button 'Accept terms' + + expect(page).not_to have_content(term.terms) + expect(user.reload.terms_accepted?).to be(true) + end + end + + context 'terms were enforced while session is active', :js do + let(:project) { create(:project) } + + before do + project.add_developer(user) + end + + it 'redirects to terms and back to where the user was going' do + visit project_path(project) + + enforce_terms + + within('.nav-sidebar') do + click_link 'Issues' + end + + expect_to_be_on_terms_page + + click_button('Accept terms') + + expect(current_path).to eq(project_issues_path(project)) + end + + it 'redirects back to the page the user was trying to save' do + visit new_project_issue_path(project) + + fill_in :issue_title, with: 'Hello world, a new issue' + fill_in :issue_description, with: "We don't want to lose what the user typed" + + enforce_terms + + click_button 'Submit issue' + + expect(current_path).to eq(terms_path) + + click_button('Accept terms') + + expect(current_path).to eq(new_project_issue_path(project)) + expect(find_field('issue_title').value).to eq('Hello world, a new issue') + expect(find_field('issue_description').value).to eq("We don't want to lose what the user typed") + end + end +end diff --git a/spec/fixtures/api/schemas/ci_detailed_status.json b/spec/fixtures/api/schemas/ci_detailed_status.json new file mode 100644 index 00000000000..01e34249bf1 --- /dev/null +++ b/spec/fixtures/api/schemas/ci_detailed_status.json @@ -0,0 +1,24 @@ +{ + "type": "object", + "required" : [ + "icon", + "text", + "label", + "group", + "tooltip", + "has_details", + "details_path", + "favicon" + ], + "properties": { + "icon": { "type": "string" }, + "text": { "type": "string" }, + "label": { "type": "string" }, + "group": { "type": "string" }, + "tooltip": { "type": "string" }, + "has_details": { "type": "boolean" }, + "details_path": { "type": "string" }, + "favicon": { "type": "string" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/entities/merge_request_widget.json b/spec/fixtures/api/schemas/entities/merge_request_widget.json index a622bf88b13..233102c4314 100644 --- a/spec/fixtures/api/schemas/entities/merge_request_widget.json +++ b/spec/fixtures/api/schemas/entities/merge_request_widget.json @@ -22,6 +22,7 @@ "in_progress_merge_commit_sha": { "type": ["string", "null"] }, "merge_error": { "type": ["string", "null"] }, "merge_commit_sha": { "type": ["string", "null"] }, + "short_merge_commit_sha": { "type": ["string", "null"] }, "merge_params": { "type": ["object", "null"] }, "merge_status": { "type": "string" }, "merge_user_id": { "type": ["integer", "null"] }, @@ -100,6 +101,7 @@ "merge_commit_message_with_description": { "type": "string" }, "diverged_commits_count": { "type": "integer" }, "commit_change_content_path": { "type": "string" }, + "merge_commit_path": { "type": ["string", "null"] }, "remove_wip_path": { "type": ["string", "null"] }, "commits_count": { "type": "integer" }, "remove_source_branch": { "type": ["boolean", "null"] }, diff --git a/spec/fixtures/api/schemas/job.json b/spec/fixtures/api/schemas/job.json new file mode 100644 index 00000000000..7b92ab25bc1 --- /dev/null +++ b/spec/fixtures/api/schemas/job.json @@ -0,0 +1,24 @@ +{ + "type": "object", + "required": [ + "id", + "name", + "started", + "build_path", + "playable", + "created_at", + "updated_at", + "status" + ], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "started": { "type": "boolean" } , + "build_path": { "type": "string" }, + "playable": { "type": "boolean" }, + "created_at": { "type": "string" }, + "updated_at": { "type": "string" }, + "status": { "$ref": "ci_detailed_status.json" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/pipeline_stage.json b/spec/fixtures/api/schemas/pipeline_stage.json new file mode 100644 index 00000000000..55454200bb3 --- /dev/null +++ b/spec/fixtures/api/schemas/pipeline_stage.json @@ -0,0 +1,24 @@ +{ + "type": "object", + "required" : [ + "name", + "title", + "status", + "path", + "dropdown_path" + ], + "properties" : { + "name": { "type": "string" }, + "title": { "type": "string" }, + "groups": { "optional": true }, + "latest_statuses": { + "type": "array", + "items": { "$ref": "job.json" }, + "optional": true + }, + "status": { "$ref": "ci_detailed_status.json" }, + "path": { "type": "string" }, + "dropdown_path": { "type": "string" } + }, + "additionalProperties": false +} diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 5e454f8b310..593b2ca1825 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -151,4 +151,16 @@ describe ApplicationHelper do end end end + + describe '#autocomplete_data_sources' do + let(:project) { create(:project) } + let(:noteable_type) { Issue } + it 'returns paths for autocomplete_sources_controller' do + sources = helper.autocomplete_data_sources(project, noteable_type) + expect(sources.keys).to match_array([:members, :issues, :merge_requests, :labels, :milestones, :commands]) + sources.keys.each do |key| + expect(sources[key]).not_to be_nil + end + end + end end diff --git a/spec/helpers/users_helper_spec.rb b/spec/helpers/users_helper_spec.rb index 6332217b920..b18c045848f 100644 --- a/spec/helpers/users_helper_spec.rb +++ b/spec/helpers/users_helper_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' describe UsersHelper do + include TermsHelper + let(:user) { create(:user) } describe '#user_link' do @@ -27,4 +29,39 @@ describe UsersHelper do expect(tabs).to include(:activity, :groups, :contributed, :projects, :snippets) end end + + describe '#current_user_menu_items' do + subject(:items) { helper.current_user_menu_items } + + before do + allow(helper).to receive(:current_user).and_return(user) + allow(helper).to receive(:can?).and_return(false) + end + + it 'includes all default items' do + expect(items).to include(:help, :sign_out) + end + + it 'includes the profile tab if the user can read themself' do + expect(helper).to receive(:can?).with(user, :read_user, user) { true } + + expect(items).to include(:profile) + end + + it 'includes the settings tab if the user can update themself' do + expect(helper).to receive(:can?).with(user, :read_user, user) { true } + + expect(items).to include(:profile) + end + + context 'when terms are enforced' do + before do + enforce_terms + end + + it 'hides the profile and the settings tab' do + expect(items).not_to include(:settings, :profile, :help) + end + end + end end diff --git a/spec/javascripts/deploy_keys/components/action_btn_spec.js b/spec/javascripts/deploy_keys/components/action_btn_spec.js index 7025c3d836c..5bf72cc0018 100644 --- a/spec/javascripts/deploy_keys/components/action_btn_spec.js +++ b/spec/javascripts/deploy_keys/components/action_btn_spec.js @@ -7,62 +7,64 @@ describe('Deploy keys action btn', () => { const deployKey = data.enabled_keys[0]; let vm; - beforeEach((done) => { - const ActionBtnComponent = Vue.extend(actionBtn); - - vm = new ActionBtnComponent({ - propsData: { - deployKey, - type: 'enable', + beforeEach(done => { + const ActionBtnComponent = Vue.extend({ + components: { + actionBtn, + }, + data() { + return { + deployKey, + }; }, - }).$mount(); + template: ` + <action-btn + :deploy-key="deployKey" + type="enable"> + Enable + </action-btn>`, + }); + + vm = new ActionBtnComponent().$mount(); - setTimeout(done); + Vue.nextTick() + .then(done) + .catch(done.fail); }); - it('renders the type as uppercase', () => { - expect( - vm.$el.textContent.trim(), - ).toBe('Enable'); + it('renders the default slot', () => { + expect(vm.$el.textContent.trim()).toBe('Enable'); }); - it('sends eventHub event with btn type', (done) => { + it('sends eventHub event with btn type', done => { spyOn(eventHub, '$emit'); vm.$el.click(); - setTimeout(() => { - expect( - eventHub.$emit, - ).toHaveBeenCalledWith('enable.key', deployKey, jasmine.anything()); + Vue.nextTick(() => { + expect(eventHub.$emit).toHaveBeenCalledWith('enable.key', deployKey, jasmine.anything()); done(); }); }); - it('shows loading spinner after click', (done) => { + it('shows loading spinner after click', done => { vm.$el.click(); - setTimeout(() => { - expect( - vm.$el.querySelector('.fa'), - ).toBeDefined(); + Vue.nextTick(() => { + expect(vm.$el.querySelector('.fa')).toBeDefined(); done(); }); }); - it('disables button after click', (done) => { + it('disables button after click', done => { vm.$el.click(); - setTimeout(() => { - expect( - vm.$el.classList.contains('disabled'), - ).toBeTruthy(); + Vue.nextTick(() => { + expect(vm.$el.classList.contains('disabled')).toBeTruthy(); - expect( - vm.$el.getAttribute('disabled'), - ).toBe('disabled'); + expect(vm.$el.getAttribute('disabled')).toBe('disabled'); done(); }); diff --git a/spec/javascripts/deploy_keys/components/app_spec.js b/spec/javascripts/deploy_keys/components/app_spec.js index b870f87eab9..3f9e25a8862 100644 --- a/spec/javascripts/deploy_keys/components/app_spec.js +++ b/spec/javascripts/deploy_keys/components/app_spec.js @@ -8,12 +8,14 @@ describe('Deploy keys app component', () => { let vm; const deployKeysResponse = (request, next) => { - next(request.respondWith(JSON.stringify(data), { - status: 200, - })); + next( + request.respondWith(JSON.stringify(data), { + status: 200, + }), + ); }; - beforeEach((done) => { + beforeEach(done => { const Component = Vue.extend(deployKeysApp); Vue.http.interceptors.push(deployKeysResponse); @@ -21,6 +23,7 @@ describe('Deploy keys app component', () => { vm = new Component({ propsData: { endpoint: '/test', + projectId: '8', }, }).$mount(); @@ -31,117 +34,112 @@ describe('Deploy keys app component', () => { Vue.http.interceptors = _.without(Vue.http.interceptors, deployKeysResponse); }); - it('renders loading icon', (done) => { + it('renders loading icon', done => { vm.store.keys = {}; vm.isLoading = false; Vue.nextTick(() => { - expect( - vm.$el.querySelectorAll('.deploy-keys-panel').length, - ).toBe(0); + expect(vm.$el.querySelectorAll('.deploy-keys .nav-links li').length).toBe(0); - expect( - vm.$el.querySelector('.fa-spinner'), - ).toBeDefined(); + expect(vm.$el.querySelector('.fa-spinner')).toBeDefined(); done(); }); }); it('renders keys panels', () => { - expect( - vm.$el.querySelectorAll('.deploy-keys-panel').length, - ).toBe(3); + expect(vm.$el.querySelectorAll('.deploy-keys .nav-links li').length).toBe(3); }); - it('does not render key panels when keys object is empty', (done) => { - vm.store.keys = {}; - - Vue.nextTick(() => { - expect( - vm.$el.querySelectorAll('.deploy-keys-panel').length, - ).toBe(0); - - done(); - }); + it('renders the titles with keys count', () => { + const textContent = selector => { + const element = vm.$el.querySelector(`${selector}`); + + expect(element).not.toBeNull(); + return element.textContent.trim(); + }; + + expect(textContent('.js-deployKeys-tab-enabled_keys')).toContain('Enabled deploy keys'); + expect(textContent('.js-deployKeys-tab-available_project_keys')).toContain( + 'Privately accessible deploy keys', + ); + expect(textContent('.js-deployKeys-tab-public_keys')).toContain( + 'Publicly accessible deploy keys', + ); + + expect(textContent('.js-deployKeys-tab-enabled_keys .badge')).toBe( + `${vm.store.keys.enabled_keys.length}`, + ); + expect(textContent('.js-deployKeys-tab-available_project_keys .badge')).toBe( + `${vm.store.keys.available_project_keys.length}`, + ); + expect(textContent('.js-deployKeys-tab-public_keys .badge')).toBe( + `${vm.store.keys.public_keys.length}`, + ); }); - it('does not render public panel when empty', (done) => { - vm.store.keys.public_keys = []; + it('does not render key panels when keys object is empty', done => { + vm.store.keys = {}; Vue.nextTick(() => { - expect( - vm.$el.querySelectorAll('.deploy-keys-panel').length, - ).toBe(2); + expect(vm.$el.querySelectorAll('.deploy-keys .nav-links li').length).toBe(0); done(); }); }); - it('re-fetches deploy keys when enabling a key', (done) => { + it('re-fetches deploy keys when enabling a key', done => { const key = data.public_keys[0]; spyOn(vm.service, 'getKeys'); - spyOn(vm.service, 'enableKey').and.callFake(() => new Promise((resolve) => { - resolve(); - - setTimeout(() => { - expect(vm.service.getKeys).toHaveBeenCalled(); - - done(); - }); - })); + spyOn(vm.service, 'enableKey').and.callFake(() => Promise.resolve()); eventHub.$emit('enable.key', key); - expect(vm.service.enableKey).toHaveBeenCalledWith(key.id); + Vue.nextTick(() => { + expect(vm.service.enableKey).toHaveBeenCalledWith(key.id); + expect(vm.service.getKeys).toHaveBeenCalled(); + done(); + }); }); - it('re-fetches deploy keys when disabling a key', (done) => { + it('re-fetches deploy keys when disabling a key', done => { const key = data.public_keys[0]; spyOn(window, 'confirm').and.returnValue(true); spyOn(vm.service, 'getKeys'); - spyOn(vm.service, 'disableKey').and.callFake(() => new Promise((resolve) => { - resolve(); - - setTimeout(() => { - expect(vm.service.getKeys).toHaveBeenCalled(); - - done(); - }); - })); + spyOn(vm.service, 'disableKey').and.callFake(() => Promise.resolve()); eventHub.$emit('disable.key', key); - expect(vm.service.disableKey).toHaveBeenCalledWith(key.id); + Vue.nextTick(() => { + expect(vm.service.disableKey).toHaveBeenCalledWith(key.id); + expect(vm.service.getKeys).toHaveBeenCalled(); + done(); + }); }); - it('calls disableKey when removing a key', (done) => { + it('calls disableKey when removing a key', done => { const key = data.public_keys[0]; spyOn(window, 'confirm').and.returnValue(true); spyOn(vm.service, 'getKeys'); - spyOn(vm.service, 'disableKey').and.callFake(() => new Promise((resolve) => { - resolve(); - - setTimeout(() => { - expect(vm.service.getKeys).toHaveBeenCalled(); - - done(); - }); - })); + spyOn(vm.service, 'disableKey').and.callFake(() => Promise.resolve()); eventHub.$emit('remove.key', key); - expect(vm.service.disableKey).toHaveBeenCalledWith(key.id); + Vue.nextTick(() => { + expect(vm.service.disableKey).toHaveBeenCalledWith(key.id); + expect(vm.service.getKeys).toHaveBeenCalled(); + done(); + }); }); it('hasKeys returns true when there are keys', () => { expect(vm.hasKeys).toEqual(3); }); - it('resets remove button loading state', (done) => { + it('resets disable button loading state', done => { spyOn(window, 'confirm').and.returnValue(false); const btn = vm.$el.querySelector('.btn-warning'); @@ -149,7 +147,7 @@ describe('Deploy keys app component', () => { btn.click(); Vue.nextTick(() => { - expect(btn.querySelector('.fa')).toBeNull(); + expect(btn.querySelector('.btn-warning')).not.toExist(); done(); }); diff --git a/spec/javascripts/deploy_keys/components/key_spec.js b/spec/javascripts/deploy_keys/components/key_spec.js index b7aadf604a4..4279add21d1 100644 --- a/spec/javascripts/deploy_keys/components/key_spec.js +++ b/spec/javascripts/deploy_keys/components/key_spec.js @@ -7,7 +7,7 @@ describe('Deploy keys key', () => { let vm; const KeyComponent = Vue.extend(key); const data = getJSONFixture('deploy_keys/keys.json'); - const createComponent = (deployKey) => { + const createComponent = deployKey => { const store = new DeployKeysStore(); store.keys = data; @@ -23,37 +23,42 @@ describe('Deploy keys key', () => { describe('enabled key', () => { const deployKey = data.enabled_keys[0]; - beforeEach((done) => { + beforeEach(done => { createComponent(deployKey); setTimeout(done); }); it('renders the keys title', () => { - expect( - vm.$el.querySelector('.title').textContent.trim(), - ).toContain('My title'); + expect(vm.$el.querySelector('.title').textContent.trim()).toContain('My title'); }); it('renders human friendly formatted created date', () => { - expect( - vm.$el.querySelector('.key-created-at').textContent.trim(), - ).toBe(`created ${getTimeago().format(deployKey.created_at)}`); + expect(vm.$el.querySelector('.key-created-at').textContent.trim()).toBe( + `${getTimeago().format(deployKey.created_at)}`, + ); }); - it('shows edit button', () => { - expect( - vm.$el.querySelectorAll('.btn')[0].textContent.trim(), - ).toBe('Edit'); + it('shows pencil button for editing', () => { + expect(vm.$el.querySelector('.btn .ic-pencil')).toExist(); }); - it('shows remove button', () => { - expect( - vm.$el.querySelectorAll('.btn')[1].textContent.trim(), - ).toBe('Remove'); + it('shows disable button when the project is not deletable', () => { + expect(vm.$el.querySelector('.btn .ic-cancel')).toExist(); }); - it('shows write access title when key has write access', (done) => { + it('shows remove button when the project is deletable', done => { + vm.deployKey.destroyed_when_orphaned = true; + vm.deployKey.almost_orphaned = true; + Vue.nextTick(() => { + expect(vm.$el.querySelector('.btn .ic-remove')).toExist(); + done(); + }); + }); + }); + + describe('deploy key labels', () => { + it('shows write access title when key has write access', done => { vm.deployKey.deploy_keys_projects[0].can_push = true; Vue.nextTick(() => { @@ -64,7 +69,7 @@ describe('Deploy keys key', () => { }); }); - it('does not show write access title when key has write access', (done) => { + it('does not show write access title when key has write access', done => { vm.deployKey.deploy_keys_projects[0].can_push = false; Vue.nextTick(() => { @@ -74,36 +79,73 @@ describe('Deploy keys key', () => { done(); }); }); + + it('shows expandable button if more than two projects', () => { + const labels = vm.$el.querySelectorAll('.deploy-project-label'); + expect(labels.length).toBe(2); + expect(labels[1].textContent).toContain('others'); + expect(labels[1].getAttribute('data-original-title')).toContain('Expand'); + }); + + it('expands all project labels after click', done => { + const length = vm.deployKey.deploy_keys_projects.length; + vm.$el.querySelectorAll('.deploy-project-label')[1].click(); + + Vue.nextTick(() => { + const labels = vm.$el.querySelectorAll('.deploy-project-label'); + expect(labels.length).toBe(length); + expect(labels[1].textContent).not.toContain(`+${length} others`); + expect(labels[1].getAttribute('data-original-title')).not.toContain('Expand'); + done(); + }); + }); + + it('shows two projects', done => { + vm.deployKey.deploy_keys_projects = [...vm.deployKey.deploy_keys_projects].slice(0, 2); + + Vue.nextTick(() => { + const labels = vm.$el.querySelectorAll('.deploy-project-label'); + expect(labels.length).toBe(2); + expect(labels[1].textContent).toContain( + vm.deployKey.deploy_keys_projects[1].project.full_name, + ); + done(); + }); + }); }); describe('public keys', () => { const deployKey = data.public_keys[0]; - beforeEach((done) => { + beforeEach(done => { createComponent(deployKey); setTimeout(done); }); - it('shows edit button', () => { - expect( - vm.$el.querySelectorAll('.btn')[0].textContent.trim(), - ).toBe('Edit'); + it('renders deploy keys without any enabled projects', done => { + vm.deployKey.deploy_keys_projects = []; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.deploy-project-list').textContent.trim()).toBe('None'); + + done(); + }); }); it('shows enable button', () => { - expect( - vm.$el.querySelectorAll('.btn')[1].textContent.trim(), - ).toBe('Enable'); + expect(vm.$el.querySelectorAll('.btn')[0].textContent.trim()).toBe('Enable'); }); - it('shows disable button when key is enabled', (done) => { + it('shows pencil button for editing', () => { + expect(vm.$el.querySelector('.btn .ic-pencil')).toExist(); + }); + + it('shows disable button when key is enabled', done => { vm.store.keys.enabled_keys.push(deployKey); Vue.nextTick(() => { - expect( - vm.$el.querySelectorAll('.btn')[1].textContent.trim(), - ).toBe('Disable'); + expect(vm.$el.querySelector('.btn .ic-cancel')).toExist(); done(); }); diff --git a/spec/javascripts/deploy_keys/components/keys_panel_spec.js b/spec/javascripts/deploy_keys/components/keys_panel_spec.js index 08357d2b547..f71f5ccf082 100644 --- a/spec/javascripts/deploy_keys/components/keys_panel_spec.js +++ b/spec/javascripts/deploy_keys/components/keys_panel_spec.js @@ -6,7 +6,7 @@ describe('Deploy keys panel', () => { const data = getJSONFixture('deploy_keys/keys.json'); let vm; - beforeEach((done) => { + beforeEach(done => { const DeployKeysPanelComponent = Vue.extend(deployKeysPanel); const store = new DeployKeysStore(); store.keys = data; @@ -24,46 +24,38 @@ describe('Deploy keys panel', () => { setTimeout(done); }); - it('renders the title with keys count', () => { - expect( - vm.$el.querySelector('h5').textContent.trim(), - ).toContain('test'); - - expect( - vm.$el.querySelector('h5').textContent.trim(), - ).toContain(`(${vm.keys.length})`); + it('renders list of keys', () => { + expect(vm.$el.querySelectorAll('.deploy-key').length).toBe(vm.keys.length); }); - it('renders list of keys', () => { - expect( - vm.$el.querySelectorAll('li').length, - ).toBe(vm.keys.length); + it('renders table header', () => { + const tableHeader = vm.$el.querySelector('.table-row-header'); + + expect(tableHeader).toExist(); + expect(tableHeader.textContent).toContain('Deploy key'); + expect(tableHeader.textContent).toContain('Project usage'); + expect(tableHeader.textContent).toContain('Created'); }); - it('renders help box if keys are empty', (done) => { + it('renders help box if keys are empty', done => { vm.keys = []; Vue.nextTick(() => { - expect( - vm.$el.querySelector('.settings-message'), - ).toBeDefined(); + expect(vm.$el.querySelector('.settings-message')).toBeDefined(); - expect( - vm.$el.querySelector('.settings-message').textContent.trim(), - ).toBe('No deploy keys found. Create one with the form above.'); + expect(vm.$el.querySelector('.settings-message').textContent.trim()).toBe( + 'No deploy keys found. Create one with the form above.', + ); done(); }); }); - it('does not render help box if keys are empty & showHelpBox is false', (done) => { + it('renders no table header if keys are empty', done => { vm.keys = []; - vm.showHelpBox = false; Vue.nextTick(() => { - expect( - vm.$el.querySelector('.settings-message'), - ).toBeNull(); + expect(vm.$el.querySelector('.table-row-header')).not.toExist(); done(); }); diff --git a/spec/javascripts/fixtures/deploy_keys.rb b/spec/javascripts/fixtures/deploy_keys.rb index 580894ceaf9..24699c3043a 100644 --- a/spec/javascripts/fixtures/deploy_keys.rb +++ b/spec/javascripts/fixtures/deploy_keys.rb @@ -7,6 +7,8 @@ describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :control let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project_empty_repo, namespace: namespace, path: 'todos-project') } let(:project2) { create(:project, :internal)} + let(:project3) { create(:project, :internal)} + let(:project4) { create(:project, :internal)} before(:all) do clean_frontend_fixtures('deploy_keys/') @@ -28,6 +30,8 @@ describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :control internal_key = create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDNd/UJWhPrpb+b/G5oL109y57yKuCxE+WUGJGYaj7WQKsYRJmLYh1mgjrl+KVyfsWpq4ylOxIfFSnN9xBBFN8mlb0Fma5DC7YsSsibJr3MZ19ZNBprwNcdogET7aW9I0In7Wu5f2KqI6e5W/spJHCy4JVxzVMUvk6Myab0LnJ2iQ== dummy@gitlab.com') create(:deploy_keys_project, project: project, deploy_key: project_key) create(:deploy_keys_project, project: project2, deploy_key: internal_key) + create(:deploy_keys_project, project: project3, deploy_key: project_key) + create(:deploy_keys_project, project: project4, deploy_key: project_key) get :index, namespace_id: project.namespace.to_param, diff --git a/spec/javascripts/fixtures/mini_dropdown_graph.html.haml b/spec/javascripts/fixtures/mini_dropdown_graph.html.haml index b532b48a95b..74584993739 100644 --- a/spec/javascripts/fixtures/mini_dropdown_graph.html.haml +++ b/spec/javascripts/fixtures/mini_dropdown_graph.html.haml @@ -4,6 +4,7 @@ %ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container %li.js-builds-dropdown-list.scrollable-menu + %ul %li.js-builds-dropdown-loading.hidden %span.fa.fa-spinner diff --git a/spec/javascripts/gpg_badges_spec.js b/spec/javascripts/gpg_badges_spec.js index 5decb5e6bbd..97c771dcfd3 100644 --- a/spec/javascripts/gpg_badges_spec.js +++ b/spec/javascripts/gpg_badges_spec.js @@ -16,8 +16,8 @@ describe('GpgBadges', () => { beforeEach(() => { mock = new MockAdapter(axios); setFixtures(` - <form - class="commits-search-form" data-signatures-path="/hello" action="/hello" + <form + class="commits-search-form js-signature-container" data-signatures-path="/hello" action="/hello" method="get"> <input name="utf8" type="hidden" value="✓"> <input type="search" name="search" id="commits-search"class="form-control search-text-input input-short"> diff --git a/spec/javascripts/ide/components/activity_bar_spec.js b/spec/javascripts/ide/components/activity_bar_spec.js new file mode 100644 index 00000000000..946c7e8e9c8 --- /dev/null +++ b/spec/javascripts/ide/components/activity_bar_spec.js @@ -0,0 +1,92 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import { activityBarViews } from '~/ide/constants'; +import ActivityBar from '~/ide/components/activity_bar.vue'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { resetStore } from '../helpers'; + +describe('IDE activity bar', () => { + const Component = Vue.extend(ActivityBar); + let vm; + + beforeEach(() => { + Vue.set(store.state.projects, 'abcproject', { + web_url: 'testing', + }); + Vue.set(store.state, 'currentProjectId', 'abcproject'); + + vm = createComponentWithStore(Component, store); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + describe('goBackUrl', () => { + it('renders the Go Back link with the referrer when present', () => { + const fakeReferrer = '/example/README.md'; + spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer); + + vm.$mount(); + + expect(vm.goBackUrl).toEqual(fakeReferrer); + }); + + it('renders the Go Back link with the project url when referrer is not present', () => { + const fakeReferrer = ''; + spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer); + + vm.$mount(); + + expect(vm.goBackUrl).toEqual('testing'); + }); + }); + + describe('updateActivityBarView', () => { + beforeEach(() => { + spyOn(vm, 'updateActivityBarView'); + + vm.$mount(); + }); + + it('calls updateActivityBarView with edit value on click', () => { + vm.$el.querySelector('.js-ide-edit-mode').click(); + + expect(vm.updateActivityBarView).toHaveBeenCalledWith(activityBarViews.edit); + }); + + it('calls updateActivityBarView with commit value on click', () => { + vm.$el.querySelector('.js-ide-commit-mode').click(); + + expect(vm.updateActivityBarView).toHaveBeenCalledWith(activityBarViews.commit); + }); + + it('calls updateActivityBarView with review value on click', () => { + vm.$el.querySelector('.js-ide-review-mode').click(); + + expect(vm.updateActivityBarView).toHaveBeenCalledWith(activityBarViews.review); + }); + }); + + describe('active item', () => { + beforeEach(() => { + vm.$mount(); + }); + + it('sets edit item active', () => { + expect(vm.$el.querySelector('.js-ide-edit-mode').classList).toContain('active'); + }); + + it('sets commit item active', done => { + vm.$store.state.currentActivityView = activityBarViews.commit; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.js-ide-commit-mode').classList).toContain('active'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/empty_state_spec.js b/spec/javascripts/ide/components/commit_sidebar/empty_state_spec.js index b80d08de7b1..16d0b354a30 100644 --- a/spec/javascripts/ide/components/commit_sidebar/empty_state_spec.js +++ b/spec/javascripts/ide/components/commit_sidebar/empty_state_spec.js @@ -10,10 +10,9 @@ describe('IDE commit panel empty state', () => { beforeEach(() => { const Component = Vue.extend(emptyState); - vm = createComponentWithStore(Component, store, { - noChangesStateSvgPath: 'no-changes', - committedStateSvgPath: 'committed-state', - }); + Vue.set(store.state, 'noChangesStateSvgPath', 'no-changes'); + + vm = createComponentWithStore(Component, store); vm.$mount(); }); @@ -24,72 +23,7 @@ describe('IDE commit panel empty state', () => { resetStore(vm.$store); }); - describe('statusSvg', () => { - it('uses noChangesStateSvgPath when commit message is empty', () => { - expect(vm.statusSvg).toBe('no-changes'); - expect(vm.$el.querySelector('img').getAttribute('src')).toBe( - 'no-changes', - ); - }); - - it('uses committedStateSvgPath when commit message exists', done => { - vm.$store.state.lastCommitMsg = 'testing'; - - Vue.nextTick(() => { - expect(vm.statusSvg).toBe('committed-state'); - expect(vm.$el.querySelector('img').getAttribute('src')).toBe( - 'committed-state', - ); - - done(); - }); - }); - }); - it('renders no changes text when last commit message is empty', () => { expect(vm.$el.textContent).toContain('No changes'); }); - - it('renders last commit message when it exists', done => { - vm.$store.state.lastCommitMsg = 'testing commit message'; - - Vue.nextTick(() => { - expect(vm.$el.textContent).toContain('testing commit message'); - - done(); - }); - }); - - describe('toggle button', () => { - it('calls store action', () => { - spyOn(vm, 'toggleRightPanelCollapsed'); - - vm.$el.querySelector('.multi-file-commit-panel-collapse-btn').click(); - - expect(vm.toggleRightPanelCollapsed).toHaveBeenCalled(); - }); - - it('renders collapsed class', done => { - vm.$el.querySelector('.multi-file-commit-panel-collapse-btn').click(); - - Vue.nextTick(() => { - expect(vm.$el.querySelector('.is-collapsed')).not.toBeNull(); - - done(); - }); - }); - }); - - describe('collapsed state', () => { - beforeEach(done => { - vm.$store.state.rightPanelCollapsed = true; - - Vue.nextTick(done); - }); - - it('does not render text & svg', () => { - expect(vm.$el.querySelector('img')).toBeNull(); - expect(vm.$el.textContent).not.toContain('No changes'); - }); - }); }); diff --git a/spec/javascripts/ide/components/commit_sidebar/form_spec.js b/spec/javascripts/ide/components/commit_sidebar/form_spec.js new file mode 100644 index 00000000000..ce7c134bc97 --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/form_spec.js @@ -0,0 +1,146 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import CommitForm from '~/ide/components/commit_sidebar/form.vue'; +import { activityBarViews } from '~/ide/constants'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper'; +import { resetStore } from '../../helpers'; + +describe('IDE commit form', () => { + const Component = Vue.extend(CommitForm); + let vm; + + beforeEach(() => { + spyOnProperty(window, 'innerHeight').and.returnValue(800); + + store.state.changedFiles.push('test'); + + vm = createComponentWithStore(Component, store).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('enables button when has changes', () => { + expect(vm.$el.querySelector('[disabled]')).toBe(null); + }); + + describe('compact', () => { + it('renders commit button in compact mode', () => { + expect(vm.$el.querySelector('.btn-primary')).not.toBeNull(); + expect(vm.$el.querySelector('.btn-primary').textContent).toContain('Commit'); + }); + + it('does not render form', () => { + expect(vm.$el.querySelector('form')).toBeNull(); + }); + + it('renders overview text', done => { + vm.$store.state.stagedFiles.push('test'); + + vm.$nextTick(() => { + expect(vm.$el.querySelector('p').textContent).toContain('1 unstaged and 1 staged changes'); + done(); + }); + }); + + it('shows form when clicking commit button', done => { + vm.$el.querySelector('.btn-primary').click(); + + vm.$nextTick(() => { + expect(vm.$el.querySelector('form')).not.toBeNull(); + + done(); + }); + }); + + it('toggles activity bar vie when clicking commit button', done => { + vm.$el.querySelector('.btn-primary').click(); + + vm.$nextTick(() => { + expect(store.state.currentActivityView).toBe(activityBarViews.commit); + + done(); + }); + }); + }); + + describe('full', () => { + beforeEach(done => { + vm.isCompact = false; + + vm.$nextTick(done); + }); + + it('updates commitMessage in store on input', done => { + const textarea = vm.$el.querySelector('textarea'); + + textarea.value = 'testing commit message'; + + textarea.dispatchEvent(new Event('input')); + + getSetTimeoutPromise() + .then(() => { + expect(vm.$store.state.commit.commitMessage).toBe('testing commit message'); + }) + .then(done) + .catch(done.fail); + }); + + it('updating currentActivityView not to commit view sets compact mode', done => { + store.state.currentActivityView = 'a'; + + vm.$nextTick(() => { + expect(vm.isCompact).toBe(true); + + done(); + }); + }); + + describe('discard draft button', () => { + it('hidden when commitMessage is empty', () => { + expect(vm.$el.querySelector('.btn-default').textContent).toContain('Collapse'); + }); + + it('resets commitMessage when clicking discard button', done => { + vm.$store.state.commit.commitMessage = 'testing commit message'; + + getSetTimeoutPromise() + .then(() => { + vm.$el.querySelector('.btn-default').click(); + }) + .then(Vue.nextTick) + .then(() => { + expect(vm.$store.state.commit.commitMessage).not.toBe('testing commit message'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('when submitting', () => { + beforeEach(() => { + spyOn(vm, 'commitChanges'); + vm.$store.state.stagedFiles.push('test'); + }); + + it('calls commitChanges', done => { + vm.$store.state.commit.commitMessage = 'testing commit message'; + + getSetTimeoutPromise() + .then(() => { + vm.$el.querySelector('.btn-success').click(); + }) + .then(Vue.nextTick) + .then(() => { + expect(vm.commitChanges).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/list_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_spec.js index da1bc102b94..54625ef90f8 100644 --- a/spec/javascripts/ide/components/commit_sidebar/list_spec.js +++ b/spec/javascripts/ide/components/commit_sidebar/list_spec.js @@ -49,45 +49,4 @@ describe('Multi-file editor commit sidebar list', () => { expect(vm.$el.textContent).toContain('No changes'); }); }); - - describe('collapsed', () => { - beforeEach(done => { - vm.$store.state.rightPanelCollapsed = true; - - Vue.nextTick(done); - }); - - it('hides list', () => { - expect(vm.$el.querySelector('.list-unstyled')).toBeNull(); - expect(vm.$el.querySelector('.form-text.text-muted')).toBeNull(); - }); - }); - - describe('with toggle', () => { - beforeEach(done => { - spyOn(vm, 'toggleRightPanelCollapsed'); - - vm.showToggle = true; - - Vue.nextTick(done); - }); - - it('calls setPanelCollapsedStatus when clickin toggle', () => { - vm.$el.querySelector('.multi-file-commit-panel-collapse-btn').click(); - - expect(vm.toggleRightPanelCollapsed).toHaveBeenCalled(); - }); - }); - - describe('action button', () => { - beforeEach(() => { - spyOn(vm, 'stageAllChanges'); - }); - - it('calls store action when clicked', () => { - vm.$el.querySelector('.ide-staged-action-btn').click(); - - expect(vm.stageAllChanges).toHaveBeenCalled(); - }); - }); }); diff --git a/spec/javascripts/ide/components/commit_sidebar/success_message_spec.js b/spec/javascripts/ide/components/commit_sidebar/success_message_spec.js new file mode 100644 index 00000000000..e1a432b81be --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/success_message_spec.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import successMessage from '~/ide/components/commit_sidebar/success_message.vue'; +import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; +import { resetStore } from '../../helpers'; + +describe('IDE commit panel successful commit state', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(successMessage); + + vm = createComponentWithStore(Component, store, { + committedStateSvgPath: 'committed-state', + }); + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders last commit message when it exists', done => { + vm.$store.state.lastCommitMsg = 'testing commit message'; + + Vue.nextTick(() => { + expect(vm.$el.textContent).toContain('testing commit message'); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_context_bar_spec.js b/spec/javascripts/ide/components/ide_context_bar_spec.js deleted file mode 100644 index e17b051f137..00000000000 --- a/spec/javascripts/ide/components/ide_context_bar_spec.js +++ /dev/null @@ -1,37 +0,0 @@ -import Vue from 'vue'; -import store from '~/ide/stores'; -import ideContextBar from '~/ide/components/ide_context_bar.vue'; -import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; - -describe('Multi-file editor right context bar', () => { - let vm; - - beforeEach(() => { - const Component = Vue.extend(ideContextBar); - - vm = createComponentWithStore(Component, store, { - noChangesStateSvgPath: 'svg', - committedStateSvgPath: 'svg', - }); - - vm.$store.state.rightPanelCollapsed = false; - - vm.$mount(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('collapsed', () => { - beforeEach(done => { - vm.$store.state.rightPanelCollapsed = true; - - Vue.nextTick(done); - }); - - it('adds collapsed class', () => { - expect(vm.$el.querySelector('.is-collapsed')).not.toBeNull(); - }); - }); -}); diff --git a/spec/javascripts/ide/components/ide_external_links_spec.js b/spec/javascripts/ide/components/ide_external_links_spec.js deleted file mode 100644 index 9f6cb459f3b..00000000000 --- a/spec/javascripts/ide/components/ide_external_links_spec.js +++ /dev/null @@ -1,43 +0,0 @@ -import Vue from 'vue'; -import ideExternalLinks from '~/ide/components/ide_external_links.vue'; -import createComponent from 'spec/helpers/vue_mount_component_helper'; - -describe('ide external links component', () => { - let vm; - let fakeReferrer; - let Component; - - const fakeProjectUrl = '/project/'; - - beforeEach(() => { - Component = Vue.extend(ideExternalLinks); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('goBackUrl', () => { - it('renders the Go Back link with the referrer when present', () => { - fakeReferrer = '/example/README.md'; - spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer); - - vm = createComponent(Component, { - projectUrl: fakeProjectUrl, - }).$mount(); - - expect(vm.goBackUrl).toEqual(fakeReferrer); - }); - - it('renders the Go Back link with the project url when referrer is not present', () => { - fakeReferrer = ''; - spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer); - - vm = createComponent(Component, { - projectUrl: fakeProjectUrl, - }).$mount(); - - expect(vm.goBackUrl).toEqual(fakeProjectUrl); - }); - }); -}); diff --git a/spec/javascripts/ide/components/ide_project_tree_spec.js b/spec/javascripts/ide/components/ide_project_tree_spec.js deleted file mode 100644 index 657682cb39c..00000000000 --- a/spec/javascripts/ide/components/ide_project_tree_spec.js +++ /dev/null @@ -1,39 +0,0 @@ -import Vue from 'vue'; -import ProjectTree from '~/ide/components/ide_project_tree.vue'; -import createComponent from 'spec/helpers/vue_mount_component_helper'; - -describe('IDE project tree', () => { - const Component = Vue.extend(ProjectTree); - let vm; - - beforeEach(() => { - vm = createComponent(Component, { - project: { - id: 1, - name: 'test', - web_url: gl.TEST_HOST, - avatar_url: '', - branches: [], - }, - }); - }); - - afterEach(() => { - vm.$destroy(); - }); - - it('renders identicon when projct has no avatar', () => { - expect(vm.$el.querySelector('.identicon')).not.toBeNull(); - }); - - it('renders avatar image if project has avatar', done => { - vm.project.avatar_url = gl.TEST_HOST; - - vm.$nextTick(() => { - expect(vm.$el.querySelector('.identicon')).toBeNull(); - expect(vm.$el.querySelector('img.avatar')).not.toBeNull(); - - done(); - }); - }); -}); diff --git a/spec/javascripts/ide/components/ide_repo_tree_spec.js b/spec/javascripts/ide/components/ide_repo_tree_spec.js deleted file mode 100644 index e0fbc90ca61..00000000000 --- a/spec/javascripts/ide/components/ide_repo_tree_spec.js +++ /dev/null @@ -1,43 +0,0 @@ -import Vue from 'vue'; -import ideRepoTree from '~/ide/components/ide_repo_tree.vue'; -import createComponent from '../../helpers/vue_mount_component_helper'; -import { file } from '../helpers'; - -describe('IdeRepoTree', () => { - let vm; - let tree; - - beforeEach(() => { - const IdeRepoTree = Vue.extend(ideRepoTree); - - tree = { - tree: [file()], - loading: false, - }; - - vm = createComponent(IdeRepoTree, { - tree, - }); - }); - - afterEach(() => { - vm.$destroy(); - }); - - it('renders a sidebar', () => { - expect(vm.$el.querySelector('.loading-file')).toBeNull(); - expect(vm.$el.querySelector('.file')).not.toBeNull(); - }); - - it('renders 3 loading files if tree is loading', done => { - tree.loading = true; - - vm.$nextTick(() => { - expect( - vm.$el.querySelectorAll('.multi-file-loading-container').length, - ).toEqual(3); - - done(); - }); - }); -}); diff --git a/spec/javascripts/ide/components/ide_review_spec.js b/spec/javascripts/ide/components/ide_review_spec.js new file mode 100644 index 00000000000..b9ee22b7c1a --- /dev/null +++ b/spec/javascripts/ide/components/ide_review_spec.js @@ -0,0 +1,69 @@ +import Vue from 'vue'; +import IdeReview from '~/ide/components/ide_review.vue'; +import store from '~/ide/stores'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { trimText } from '../../helpers/vue_component_helper'; +import { resetStore, file } from '../helpers'; +import { projectData } from '../mock_data'; + +describe('IDE review mode', () => { + const Component = Vue.extend(IdeReview); + let vm; + + beforeEach(() => { + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = Object.assign({}, projectData); + Vue.set(store.state.trees, 'abcproject/master', { + tree: [file('fileName')], + loading: false, + }); + + vm = createComponentWithStore(Component, store).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders list of files', () => { + expect(vm.$el.textContent).toContain('fileName'); + }); + + describe('merge request', () => { + beforeEach(done => { + store.state.currentMergeRequestId = '1'; + store.state.projects.abcproject.mergeRequests['1'] = { + iid: 123, + web_url: 'testing123', + }; + + vm.$nextTick(done); + }); + + it('renders edit dropdown', () => { + expect(vm.$el.querySelector('.btn')).not.toBe(null); + }); + + it('renders merge request link & IID', () => { + const link = vm.$el.querySelector('.ide-review-sub-header'); + + expect(link.querySelector('a').getAttribute('href')).toBe('testing123'); + expect(trimText(link.textContent)).toBe('Merge request (!123)'); + }); + + it('changes text to latest changes when viewer is not mrdiff', done => { + store.state.viewer = 'diff'; + + vm.$nextTick(() => { + expect(trimText(vm.$el.querySelector('.ide-review-sub-header').textContent)).toBe( + 'Latest changes', + ); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_side_bar_spec.js b/spec/javascripts/ide/components/ide_side_bar_spec.js index 699dae1ce2f..20ee20bc1d7 100644 --- a/spec/javascripts/ide/components/ide_side_bar_spec.js +++ b/spec/javascripts/ide/components/ide_side_bar_spec.js @@ -1,8 +1,10 @@ import Vue from 'vue'; import store from '~/ide/stores'; import ideSidebar from '~/ide/components/ide_side_bar.vue'; +import { activityBarViews } from '~/ide/constants'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { resetStore } from '../helpers'; +import { projectData } from '../mock_data'; describe('IdeSidebar', () => { let vm; @@ -10,6 +12,9 @@ describe('IdeSidebar', () => { beforeEach(() => { const Component = Vue.extend(ideSidebar); + store.state.currentProjectId = 'abcproject'; + store.state.projects.abcproject = projectData; + vm = createComponentWithStore(Component, store).$mount(); }); @@ -20,23 +25,33 @@ describe('IdeSidebar', () => { }); it('renders a sidebar', () => { - expect( - vm.$el.querySelector('.multi-file-commit-panel-inner'), - ).not.toBeNull(); + expect(vm.$el.querySelector('.multi-file-commit-panel-inner')).not.toBeNull(); }); it('renders loading icon component', done => { vm.$store.state.loading = true; vm.$nextTick(() => { - expect( - vm.$el.querySelector('.multi-file-loading-container'), - ).not.toBeNull(); - expect( - vm.$el.querySelectorAll('.multi-file-loading-container').length, - ).toBe(3); + expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull(); + expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3); done(); }); }); + + describe('activityBarComponent', () => { + it('renders tree component', () => { + expect(vm.$el.querySelector('.ide-file-list')).not.toBeNull(); + }); + + it('renders commit component', done => { + vm.$store.state.currentActivityView = activityBarViews.commit; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.multi-file-commit-panel-section')).not.toBeNull(); + + done(); + }); + }); + }); }); diff --git a/spec/javascripts/ide/components/ide_spec.js b/spec/javascripts/ide/components/ide_spec.js index 7bfcfc90572..6f580e1f7af 100644 --- a/spec/javascripts/ide/components/ide_spec.js +++ b/spec/javascripts/ide/components/ide_spec.js @@ -4,6 +4,7 @@ import store from '~/ide/stores'; import ide from '~/ide/components/ide.vue'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { file, resetStore } from '../helpers'; +import { projectData } from '../mock_data'; describe('ide component', () => { let vm; @@ -11,6 +12,10 @@ describe('ide component', () => { beforeEach(() => { const Component = Vue.extend(ide); + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = Object.assign({}, projectData); + vm = createComponentWithStore(Component, store, { emptyStateSvgPath: 'svg', noChangesStateSvgPath: 'svg', @@ -24,11 +29,11 @@ describe('ide component', () => { resetStore(vm.$store); }); - it('does not render panel right when no files open', () => { + it('does not render right right when no files open', () => { expect(vm.$el.querySelector('.panel-right')).toBeNull(); }); - it('renders panel right when files are open', done => { + it('renders right panel when files are open', done => { vm.$store.state.trees['abcproject/mybranch'] = { tree: [file()], }; diff --git a/spec/javascripts/ide/components/ide_status_bar_spec.js b/spec/javascripts/ide/components/ide_status_bar_spec.js new file mode 100644 index 00000000000..770dca9cb0f --- /dev/null +++ b/spec/javascripts/ide/components/ide_status_bar_spec.js @@ -0,0 +1,63 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import ideStatusBar from '~/ide/components/ide_status_bar.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from '../helpers'; +import { projectData } from '../mock_data'; + +describe('ideStatusBar', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(ideStatusBar); + + store.state.currentProjectId = 'abcproject'; + store.state.projects.abcproject = projectData; + + vm = createComponentWithStore(Component, store).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders the statusbar', () => { + expect(vm.$el.className).toBe('ide-status-bar'); + }); + + describe('mounted', () => { + it('triggers a setInterval', () => { + expect(vm.intervalId).not.toBe(null); + }); + }); + + describe('commitAgeUpdate', () => { + beforeEach(function() { + jasmine.clock().install(); + spyOn(vm, 'commitAgeUpdate').and.callFake(() => {}); + vm.startTimer(); + }); + + afterEach(function() { + jasmine.clock().uninstall(); + }); + + it('gets called every second', () => { + expect(vm.commitAgeUpdate).not.toHaveBeenCalled(); + + jasmine.clock().tick(1100); + expect(vm.commitAgeUpdate.calls.count()).toEqual(1); + + jasmine.clock().tick(1000); + expect(vm.commitAgeUpdate.calls.count()).toEqual(2); + }); + }); + + describe('getCommitPath', () => { + it('returns the path to the commit details', () => { + expect(vm.getCommitPath('abc123de')).toBe('/commit/abc123de'); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_tree_list_spec.js b/spec/javascripts/ide/components/ide_tree_list_spec.js new file mode 100644 index 00000000000..4ecbdb8a55e --- /dev/null +++ b/spec/javascripts/ide/components/ide_tree_list_spec.js @@ -0,0 +1,54 @@ +import Vue from 'vue'; +import IdeTreeList from '~/ide/components/ide_tree_list.vue'; +import store from '~/ide/stores'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { resetStore, file } from '../helpers'; +import { projectData } from '../mock_data'; + +describe('IDE tree list', () => { + const Component = Vue.extend(IdeTreeList); + let vm; + + beforeEach(() => { + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = Object.assign({}, projectData); + Vue.set(store.state.trees, 'abcproject/master', { + tree: [file('fileName')], + loading: false, + }); + + vm = createComponentWithStore(Component, store, { + viewerType: 'edit', + }); + + spyOn(vm, 'updateViewer').and.callThrough(); + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('updates viewer on mount', () => { + expect(vm.updateViewer).toHaveBeenCalledWith('edit'); + }); + + it('renders loading indicator', done => { + store.state.trees['abcproject/master'].loading = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull(); + expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3); + + done(); + }); + }); + + it('renders list of files', () => { + expect(vm.$el.textContent).toContain('fileName'); + }); +}); diff --git a/spec/javascripts/ide/components/ide_tree_spec.js b/spec/javascripts/ide/components/ide_tree_spec.js new file mode 100644 index 00000000000..97a0a2432f1 --- /dev/null +++ b/spec/javascripts/ide/components/ide_tree_spec.js @@ -0,0 +1,34 @@ +import Vue from 'vue'; +import IdeTree from '~/ide/components/ide_tree.vue'; +import store from '~/ide/stores'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { resetStore, file } from '../helpers'; +import { projectData } from '../mock_data'; + +describe('IdeRepoTree', () => { + let vm; + + beforeEach(() => { + const IdeRepoTree = Vue.extend(IdeTree); + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = Object.assign({}, projectData); + Vue.set(store.state.trees, 'abcproject/master', { + tree: [file('fileName')], + loading: false, + }); + + vm = createComponentWithStore(IdeRepoTree, store).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders list of files', () => { + expect(vm.$el.textContent).toContain('fileName'); + }); +}); diff --git a/spec/javascripts/ide/components/repo_commit_section_spec.js b/spec/javascripts/ide/components/repo_commit_section_spec.js index f2b9c14c11c..5e3e00a180b 100644 --- a/spec/javascripts/ide/components/repo_commit_section_spec.js +++ b/spec/javascripts/ide/components/repo_commit_section_spec.js @@ -1,9 +1,9 @@ import Vue from 'vue'; import store from '~/ide/stores'; import service from '~/ide/services'; +import router from '~/ide/ide_router'; import repoCommitSection from '~/ide/components/repo_commit_section.vue'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; -import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper'; import { file, resetStore } from '../helpers'; describe('RepoCommitSection', () => { @@ -12,10 +12,10 @@ describe('RepoCommitSection', () => { function createComponent() { const Component = Vue.extend(repoCommitSection); - vm = createComponentWithStore(Component, store, { - noChangesStateSvgPath: 'svg', - committedStateSvgPath: 'commitsvg', - }); + store.state.noChangesStateSvgPath = 'svg'; + store.state.committedStateSvgPath = 'commitsvg'; + + vm = createComponentWithStore(Component, store); vm.$store.state.currentProjectId = 'abcproject'; vm.$store.state.currentBranchId = 'master'; @@ -60,6 +60,8 @@ describe('RepoCommitSection', () => { } beforeEach(done => { + spyOn(router, 'push'); + vm = createComponent(); spyOn(service, 'getTreeData').and.returnValue( @@ -93,61 +95,49 @@ describe('RepoCommitSection', () => { resetStore(vm.$store); const Component = Vue.extend(repoCommitSection); - vm = createComponentWithStore(Component, store, { - noChangesStateSvgPath: 'nochangessvg', - committedStateSvgPath: 'svg', - }).$mount(); + store.state.noChangesStateSvgPath = 'nochangessvg'; + store.state.committedStateSvgPath = 'svg'; - expect( - vm.$el.querySelector('.js-empty-state').textContent.trim(), - ).toContain('No changes'); - expect( - vm.$el.querySelector('.js-empty-state img').getAttribute('src'), - ).toBe('nochangessvg'); + vm = createComponentWithStore(Component, store).$mount(); + + expect(vm.$el.querySelector('.js-empty-state').textContent.trim()).toContain('No changes'); + expect(vm.$el.querySelector('.js-empty-state img').getAttribute('src')).toBe('nochangessvg'); }); }); it('renders a commit section', () => { - const changedFileElements = [ - ...vm.$el.querySelectorAll('.multi-file-commit-list li'), - ]; - const submitCommit = vm.$el.querySelector('form .btn'); - const allFiles = vm.$store.state.changedFiles.concat( - vm.$store.state.stagedFiles, - ); + const changedFileElements = [...vm.$el.querySelectorAll('.multi-file-commit-list li')]; + const allFiles = vm.$store.state.changedFiles.concat(vm.$store.state.stagedFiles); - expect(vm.$el.querySelector('.multi-file-commit-form')).not.toBeNull(); expect(changedFileElements.length).toEqual(4); changedFileElements.forEach((changedFile, i) => { expect(changedFile.textContent.trim()).toContain(allFiles[i].path); }); - - expect(submitCommit.disabled).toBeTruthy(); - expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeNull(); }); it('adds changed files into staged files', done => { - vm.$el.querySelector('.ide-staged-action-btn').click(); - - Vue.nextTick(() => { - expect( - vm.$el.querySelector('.ide-commit-list-container').textContent, - ).toContain('No changes'); - - done(); - }); + vm.$el.querySelector('.multi-file-discard-btn .btn').click(); + vm + .$nextTick() + .then(() => vm.$el.querySelector('.multi-file-discard-btn .btn').click()) + .then(vm.$nextTick) + .then(() => { + expect(vm.$el.querySelector('.ide-commit-list-container').textContent).toContain( + 'No changes', + ); + }) + .then(done) + .catch(done.fail); }); it('stages a single file', done => { vm.$el.querySelector('.multi-file-discard-btn .btn').click(); Vue.nextTick(() => { - expect( - vm.$el - .querySelector('.ide-commit-list-container') - .querySelectorAll('li').length, - ).toBe(1); + expect(vm.$el.querySelector('.ide-commit-list-container').querySelectorAll('li').length).toBe( + 1, + ); done(); }); @@ -157,26 +147,10 @@ describe('RepoCommitSection', () => { vm.$el.querySelectorAll('.multi-file-discard-btn .btn')[1].click(); Vue.nextTick(() => { - expect( - vm.$el.querySelector('.ide-commit-list-container').textContent, - ).not.toContain('file1'); - expect( - vm.$el - .querySelector('.ide-commit-list-container') - .querySelectorAll('li').length, - ).toBe(1); - - done(); - }); - }); - - it('removes all staged files', done => { - vm.$el.querySelectorAll('.ide-staged-action-btn')[1].click(); - - Vue.nextTick(() => { - expect( - vm.$el.querySelectorAll('.ide-commit-list-container')[1].textContent, - ).toContain('No changes'); + expect(vm.$el.querySelector('.ide-commit-list-container').textContent).not.toContain('file1'); + expect(vm.$el.querySelector('.ide-commit-list-container').querySelectorAll('li').length).toBe( + 1, + ); done(); }); @@ -190,75 +164,17 @@ describe('RepoCommitSection', () => { Vue.nextTick(() => { expect( - vm.$el - .querySelectorAll('.ide-commit-list-container')[1] - .querySelectorAll('li').length, + vm.$el.querySelectorAll('.ide-commit-list-container')[1].querySelectorAll('li').length, ).toBe(1); done(); }); }); - it('updates commitMessage in store on input', done => { - const textarea = vm.$el.querySelector('textarea'); - - textarea.value = 'testing commit message'; - - textarea.dispatchEvent(new Event('input')); - - getSetTimeoutPromise() - .then(() => { - expect(vm.$store.state.commit.commitMessage).toBe( - 'testing commit message', - ); - }) - .then(done) - .catch(done.fail); - }); - - describe('discard draft button', () => { - it('hidden when commitMessage is empty', () => { - expect( - vm.$el.querySelector('.multi-file-commit-form .btn-secondary'), - ).toBeNull(); - }); - - it('resets commitMessage when clicking discard button', done => { - vm.$store.state.commit.commitMessage = 'testing commit message'; - - getSetTimeoutPromise() - .then(() => { - vm.$el.querySelector('.multi-file-commit-form .btn-secondary').click(); - }) - .then(Vue.nextTick) - .then(() => { - expect(vm.$store.state.commit.commitMessage).not.toBe( - 'testing commit message', - ); - }) - .then(done) - .catch(done.fail); - }); - }); - - describe('when submitting', () => { - beforeEach(() => { - spyOn(vm, 'commitChanges'); - }); - - it('calls commitChanges', done => { - vm.$store.state.commit.commitMessage = 'testing commit message'; - - getSetTimeoutPromise() - .then(() => { - vm.$el.querySelector('.multi-file-commit-form .btn-success').click(); - }) - .then(Vue.nextTick) - .then(() => { - expect(vm.commitChanges).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + describe('mounted', () => { + it('opens last opened file', () => { + expect(store.state.openFiles.length).toBe(1); + expect(store.state.openFiles[0].pending).toBe(true); }); }); }); diff --git a/spec/javascripts/ide/components/repo_editor_spec.js b/spec/javascripts/ide/components/repo_editor_spec.js index b06a6c62a1c..360b6d4dc15 100644 --- a/spec/javascripts/ide/components/repo_editor_spec.js +++ b/spec/javascripts/ide/components/repo_editor_spec.js @@ -5,6 +5,7 @@ import store from '~/ide/stores'; import repoEditor from '~/ide/components/repo_editor.vue'; import monacoLoader from '~/ide/monaco_loader'; import Editor from '~/ide/lib/editor'; +import { activityBarViews } from '~/ide/constants'; import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; import setTimeoutPromise from '../../helpers/set_timeout_promise_helper'; import { file, resetStore } from '../helpers'; @@ -295,4 +296,30 @@ describe('RepoEditor', () => { }); }); }); + + describe('show tabs', () => { + it('shows tabs in edit mode', () => { + expect(vm.$el.querySelector('.nav-links')).not.toBe(null); + }); + + it('hides tabs in review mode', done => { + vm.$store.state.currentActivityView = activityBarViews.review; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.nav-links')).toBe(null); + + done(); + }); + }); + + it('hides tabs in commit mode', done => { + vm.$store.state.currentActivityView = activityBarViews.commit; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.nav-links')).toBe(null); + + done(); + }); + }); + }); }); diff --git a/spec/javascripts/ide/components/repo_file_spec.js b/spec/javascripts/ide/components/repo_file_spec.js index ff391cb4351..156233653ab 100644 --- a/spec/javascripts/ide/components/repo_file_spec.js +++ b/spec/javascripts/ide/components/repo_file_spec.js @@ -48,6 +48,70 @@ describe('RepoFile', () => { }); }); + describe('folder', () => { + it('renders changes count inside folder', () => { + const f = { + ...file('folder'), + path: 'testing', + type: 'tree', + branchId: 'master', + projectId: 'project', + }; + + store.state.changedFiles.push({ + ...file('fileName'), + path: 'testing/fileName', + }); + + createComponent({ + file: f, + level: 0, + }); + + const treeChangesEl = vm.$el.querySelector('.ide-tree-changes'); + + expect(treeChangesEl).not.toBeNull(); + expect(treeChangesEl.textContent).toContain('1'); + }); + + it('renders action dropdown', done => { + createComponent({ + file: { + ...file('t4'), + type: 'tree', + branchId: 'master', + projectId: 'project', + }, + level: 0, + }); + + setTimeout(() => { + expect(vm.$el.querySelector('.ide-new-btn')).not.toBeNull(); + + done(); + }); + }); + + it('disables action dropdown', done => { + createComponent({ + file: { + ...file('t4'), + type: 'tree', + branchId: 'master', + projectId: 'project', + }, + level: 0, + disableActionDropdown: true, + }); + + setTimeout(() => { + expect(vm.$el.querySelector('.ide-new-btn')).toBeNull(); + + done(); + }); + }); + }); + describe('locked file', () => { let f; @@ -72,8 +136,7 @@ describe('RepoFile', () => { it('renders a tooltip', () => { expect( - vm.$el.querySelector('.ide-file-name span:nth-child(2)').dataset - .originalTitle, + vm.$el.querySelector('.ide-file-name span:nth-child(2)').dataset.originalTitle, ).toContain('Locked by testuser'); }); }); diff --git a/spec/javascripts/ide/components/repo_tabs_spec.js b/spec/javascripts/ide/components/repo_tabs_spec.js index cb785ba2cd3..583f71e6121 100644 --- a/spec/javascripts/ide/components/repo_tabs_spec.js +++ b/spec/javascripts/ide/components/repo_tabs_spec.js @@ -26,60 +26,10 @@ describe('RepoTabs', () => { const tabs = [...vm.$el.querySelectorAll('.multi-file-tab')]; expect(tabs.length).toEqual(2); - expect(tabs[0].classList.contains('active')).toEqual(true); - expect(tabs[1].classList.contains('active')).toEqual(false); + expect(tabs[0].parentNode.classList.contains('active')).toEqual(true); + expect(tabs[1].parentNode.classList.contains('active')).toEqual(false); done(); }); }); - - describe('updated', () => { - it('sets showShadow as true when scroll width is larger than width', done => { - const el = document.createElement('div'); - el.innerHTML = '<div id="test-app"></div>'; - document.body.appendChild(el); - - const style = document.createElement('style'); - style.innerText = ` - .multi-file-tabs { - width: 100px; - } - - .multi-file-tabs .list-unstyled { - display: flex; - overflow-x: auto; - } - `; - document.head.appendChild(style); - - vm = createComponent( - RepoTabs, - { - files: [], - viewer: 'editor', - hasChanges: false, - activeFile: file('activeFile'), - hasMergeRequest: false, - }, - '#test-app', - ); - - vm - .$nextTick() - .then(() => { - expect(vm.showShadow).toEqual(false); - - vm.files = openedFiles; - }) - .then(vm.$nextTick) - .then(() => { - expect(vm.showShadow).toEqual(true); - - style.remove(); - el.remove(); - }) - .then(done) - .catch(done.fail); - }); - }); }); diff --git a/spec/javascripts/ide/lib/editor_spec.js b/spec/javascripts/ide/lib/editor_spec.js index 530bdfa2759..b88a12264ca 100644 --- a/spec/javascripts/ide/lib/editor_spec.js +++ b/spec/javascripts/ide/lib/editor_spec.js @@ -74,10 +74,10 @@ describe('Multi-file editor library', () => { scrollBeyondLastLine: false, quickSuggestions: false, occurrencesHighlight: false, - renderLineHighlight: 'none', - hideCursorInOverviewRuler: true, wordWrap: 'on', renderSideBySide: true, + renderLineHighlight: 'all', + hideCursorInOverviewRuler: false, }); }); }); diff --git a/spec/javascripts/ide/mock_data.js b/spec/javascripts/ide/mock_data.js new file mode 100644 index 00000000000..3c6d75ab5e4 --- /dev/null +++ b/spec/javascripts/ide/mock_data.js @@ -0,0 +1,15 @@ +// eslint-disable-next-line import/prefer-default-export +export const projectData = { + id: 1, + name: 'abcproject', + web_url: '', + avatar_url: '', + path: '', + name_with_namespace: 'namespace/abcproject', + branches: { + master: { + treeId: 'abcproject/master', + }, + }, + mergeRequests: {}, +}; diff --git a/spec/javascripts/ide/stores/actions/file_spec.js b/spec/javascripts/ide/stores/actions/file_spec.js index ce5c525bed7..3ef5a859001 100644 --- a/spec/javascripts/ide/stores/actions/file_spec.js +++ b/spec/javascripts/ide/stores/actions/file_spec.js @@ -398,6 +398,20 @@ describe('IDE store file actions', () => { }) .catch(done.fail); }); + + it('bursts unused seal', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => { + expect(store.state.unusedSeal).toBe(false); + + done(); + }) + .catch(done.fail); + }); }); describe('discardFileChanges', () => { @@ -497,7 +511,10 @@ describe('IDE store file actions', () => { actions.stageChange, 'path', store.state, - [{ type: types.STAGE_CHANGE, payload: 'path' }], + [ + { type: types.STAGE_CHANGE, payload: 'path' }, + { type: types.SET_LAST_COMMIT_MSG, payload: '' }, + ], [], done, ); @@ -510,7 +527,10 @@ describe('IDE store file actions', () => { actions.unstageChange, 'path', store.state, - [{ type: types.UNSTAGE_CHANGE, payload: 'path' }], + [ + { type: types.UNSTAGE_CHANGE, payload: 'path' }, + { type: types.SET_LAST_COMMIT_MSG, payload: '' }, + ], [], done, ); @@ -575,20 +595,6 @@ describe('IDE store file actions', () => { .then(done) .catch(done.fail); }); - - it('returns false when passed in file is active & viewer is diff', done => { - f.active = true; - store.state.openFiles.push(f); - store.state.viewer = 'diff'; - - store - .dispatch('openPendingTab', { file: f, keyPrefix: 'pending' }) - .then(added => { - expect(added).toBe(false); - }) - .then(done) - .catch(done.fail); - }); }); describe('removePendingTab', () => { diff --git a/spec/javascripts/ide/stores/actions/project_spec.js b/spec/javascripts/ide/stores/actions/project_spec.js new file mode 100644 index 00000000000..ebd08d95810 --- /dev/null +++ b/spec/javascripts/ide/stores/actions/project_spec.js @@ -0,0 +1,71 @@ +import { + refreshLastCommitData, +} from '~/ide/stores/actions'; +import store from '~/ide/stores'; +import service from '~/ide/services'; +import { resetStore } from '../../helpers'; +import testAction from '../../../helpers/vuex_action_helper'; + +describe('IDE store project actions', () => { + beforeEach(() => { + store.state.projects.abcproject = {}; + }); + + afterEach(() => { + resetStore(store); + }); + + describe('refreshLastCommitData', () => { + beforeEach(() => { + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + branches: { + master: { + commit: null, + }, + }, + }; + }); + + it('calls the service', done => { + spyOn(service, 'getBranchData').and.returnValue( + Promise.resolve({ + data: { + commit: { id: '123' }, + }, + }), + ); + + store + .dispatch('refreshLastCommitData', { + projectId: store.state.currentProjectId, + branchId: store.state.currentBranchId, + }) + .then(() => { + expect(service.getBranchData).toHaveBeenCalledWith('abcproject', 'master'); + + done(); + }) + .catch(done.fail); + }); + + it('commits getBranchData', done => { + testAction( + refreshLastCommitData, + {}, + {}, + [{ + type: 'SET_BRANCH_COMMIT', + payload: { + projectId: 'abcproject', + branchId: 'master', + commit: { id: '123' }, + }, + }], // mutations + [], // action + done, + ); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/actions_spec.js b/spec/javascripts/ide/stores/actions_spec.js index a64af5b941b..062c3497623 100644 --- a/spec/javascripts/ide/stores/actions_spec.js +++ b/spec/javascripts/ide/stores/actions_spec.js @@ -2,6 +2,9 @@ import actions, { stageAllChanges, unstageAllChanges, toggleFileFinder, + setCurrentBranchId, + setEmptyStateSvgs, + updateActivityBarView, updateTempFlagForEntry, } from '~/ide/stores/actions'; import store from '~/ide/stores'; @@ -306,6 +309,7 @@ describe('Multi-file store actions', () => { null, store.state, [ + { type: types.SET_LAST_COMMIT_MSG, payload: '' }, { type: types.STAGE_CHANGE, payload: store.state.changedFiles[0].path }, { type: types.STAGE_CHANGE, payload: store.state.changedFiles[1].path }, ], @@ -345,6 +349,32 @@ describe('Multi-file store actions', () => { }); }); + describe('updateActivityBarView', () => { + it('commits UPDATE_ACTIVITY_BAR_VIEW', done => { + testAction( + updateActivityBarView, + 'test', + {}, + [{ type: 'UPDATE_ACTIVITY_BAR_VIEW', payload: 'test' }], + [], + done, + ); + }); + }); + + describe('setEmptyStateSvgs', () => { + it('commits setEmptyStateSvgs', done => { + testAction( + setEmptyStateSvgs, + 'svg', + {}, + [{ type: 'SET_EMPTY_STATE_SVGS', payload: 'svg' }], + [], + done, + ); + }); + }); + describe('updateTempFlagForEntry', () => { it('commits UPDATE_TEMP_FLAG', done => { const f = { @@ -388,6 +418,19 @@ describe('Multi-file store actions', () => { }); }); + describe('setCurrentBranchId', () => { + it('commits setCurrentBranchId', done => { + testAction( + setCurrentBranchId, + 'branchId', + {}, + [{ type: 'SET_CURRENT_BRANCH', payload: 'branchId' }], + [], + done, + ); + }); + }); + describe('toggleFileFinder', () => { it('commits TOGGLE_FILE_FINDER', done => { testAction( diff --git a/spec/javascripts/ide/stores/getters_spec.js b/spec/javascripts/ide/stores/getters_spec.js index b6b4dd28729..4833ba3edfd 100644 --- a/spec/javascripts/ide/stores/getters_spec.js +++ b/spec/javascripts/ide/stores/getters_spec.js @@ -37,12 +37,6 @@ describe('IDE store getters', () => { expect(modifiedFiles.length).toBe(1); expect(modifiedFiles[0].name).toBe('changed'); }); - - it('returns angle left when collapsed', () => { - localState.rightPanelCollapsed = true; - - expect(getters.collapseButtonIcon(localState)).toBe('angle-double-left'); - }); }); describe('currentMergeRequest', () => { @@ -84,4 +78,87 @@ describe('IDE store getters', () => { expect(getters.allBlobs(localState)[0].name).toBe('blob'); }); }); + + describe('getChangesInFolder', () => { + it('returns length of changed files for a path', () => { + localState.changedFiles.push( + { + path: 'test/index', + name: 'index', + }, + { + path: 'app/123', + name: '123', + }, + ); + + expect(getters.getChangesInFolder(localState)('test')).toBe(1); + }); + + it('returns length of changed & staged files for a path', () => { + localState.changedFiles.push( + { + path: 'test/index', + name: 'index', + }, + { + path: 'testing/123', + name: '123', + }, + ); + + localState.stagedFiles.push( + { + path: 'test/123', + name: '123', + }, + { + path: 'test/index', + name: 'index', + }, + { + path: 'testing/12345', + name: '12345', + }, + ); + + expect(getters.getChangesInFolder(localState)('test')).toBe(2); + }); + + it('returns length of changed & tempFiles files for a path', () => { + localState.changedFiles.push( + { + path: 'test/index', + name: 'index', + }, + { + path: 'test/newfile', + name: 'newfile', + tempFile: true, + }, + ); + + expect(getters.getChangesInFolder(localState)('test')).toBe(2); + }); + }); + + describe('lastCommit', () => { + it('returns the last commit of the current branch on the current project', () => { + const commitTitle = 'Example commit title'; + const localGetters = { + currentProject: { + branches: { + 'example-branch': { + commit: { + title: commitTitle, + }, + }, + }, + }, + }; + localState.currentBranchId = 'example-branch'; + + expect(getters.lastCommit(localState, localGetters).title).toBe(commitTitle); + }); + }); }); diff --git a/spec/javascripts/ide/stores/modules/commit/actions_spec.js b/spec/javascripts/ide/stores/modules/commit/actions_spec.js index b2b4b85ca42..a2869ff378b 100644 --- a/spec/javascripts/ide/stores/modules/commit/actions_spec.js +++ b/spec/javascripts/ide/stores/modules/commit/actions_spec.js @@ -289,21 +289,6 @@ describe('IDE commit module actions', () => { .then(done) .catch(done.fail); }); - - it('pushes route to new branch if commitAction is new branch', done => { - store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH; - - store - .dispatch('commit/updateFilesAfterCommit', { - data, - branch, - }) - .then(() => { - expect(router.push).toHaveBeenCalledWith(`/project/abcproject/blob/master/${f.path}`); - }) - .then(done) - .catch(done.fail); - }); }); describe('commitChanges', () => { @@ -391,21 +376,6 @@ describe('IDE commit module actions', () => { .catch(done.fail); }); - it('pushes router to new route', done => { - store - .dispatch('commit/commitChanges') - .then(() => { - expect(router.push).toHaveBeenCalledWith( - `/project/${store.state.currentProjectId}/blob/${ - store.getters['commit/newBranchName'] - }/changed`, - ); - - done(); - }) - .catch(done.fail); - }); - it('sets last Commit Msg', done => { store .dispatch('commit/commitChanges') diff --git a/spec/javascripts/ide/stores/mutations/branch_spec.js b/spec/javascripts/ide/stores/mutations/branch_spec.js index a7167537ef2..29eb859ddaf 100644 --- a/spec/javascripts/ide/stores/mutations/branch_spec.js +++ b/spec/javascripts/ide/stores/mutations/branch_spec.js @@ -15,4 +15,26 @@ describe('Multi-file store branch mutations', () => { expect(localState.currentBranchId).toBe('master'); }); }); + + describe('SET_BRANCH_COMMIT', () => { + it('sets the last commit on current project', () => { + localState.projects = { + Example: { + branches: { + master: {}, + }, + }, + }; + + mutations.SET_BRANCH_COMMIT(localState, { + projectId: 'Example', + branchId: 'master', + commit: { + title: 'Example commit', + }, + }); + + expect(localState.projects.Example.branches.master.commit.title).toBe('Example commit'); + }); + }); }); diff --git a/spec/javascripts/ide/stores/mutations/file_spec.js b/spec/javascripts/ide/stores/mutations/file_spec.js index 6fba934810d..e83961fcedc 100644 --- a/spec/javascripts/ide/stores/mutations/file_spec.js +++ b/spec/javascripts/ide/stores/mutations/file_spec.js @@ -267,41 +267,23 @@ describe('IDE store file mutations', () => { it('adds file into openFiles as pending', () => { mutations.ADD_PENDING_TAB(localState, { file: localFile }); - expect(localState.openFiles.length).toBe(2); - expect(localState.openFiles[1].pending).toBe(true); - expect(localState.openFiles[1].key).toBe(`pending-${localFile.key}`); - }); - - it('updates open file to pending', () => { - mutations.ADD_PENDING_TAB(localState, { file: localState.openFiles[0] }); - expect(localState.openFiles.length).toBe(1); + expect(localState.openFiles[0].pending).toBe(true); + expect(localState.openFiles[0].key).toBe(`pending-${localFile.key}`); }); - it('updates pending open file to active', () => { - localState.openFiles.push({ - ...localFile, - pending: true, - }); + it('only allows 1 open pending file', () => { + const newFile = file('test'); + localState.entries[newFile.path] = newFile; mutations.ADD_PENDING_TAB(localState, { file: localFile }); - expect(localState.openFiles[1].pending).toBe(true); - expect(localState.openFiles[1].active).toBe(true); - }); - - it('sets all openFiles to not active', () => { - mutations.ADD_PENDING_TAB(localState, { file: localFile }); + expect(localState.openFiles.length).toBe(1); - expect(localState.openFiles.length).toBe(2); + mutations.ADD_PENDING_TAB(localState, { file: file('test') }); - localState.openFiles.forEach(f => { - if (f.pending) { - expect(f.active).toBe(true); - } else { - expect(f.active).toBe(false); - } - }); + expect(localState.openFiles.length).toBe(1); + expect(localState.openFiles[0].name).toBe('test'); }); }); diff --git a/spec/javascripts/ide/stores/mutations_spec.js b/spec/javascripts/ide/stores/mutations_spec.js index 997711d1e19..972713c5ad2 100644 --- a/spec/javascripts/ide/stores/mutations_spec.js +++ b/spec/javascripts/ide/stores/mutations_spec.js @@ -87,6 +87,28 @@ describe('Multi-file store mutations', () => { }); }); + describe('UPDATE_ACTIVITY_BAR_VIEW', () => { + it('updates currentActivityBar', () => { + mutations.UPDATE_ACTIVITY_BAR_VIEW(localState, 'test'); + + expect(localState.currentActivityView).toBe('test'); + }); + }); + + describe('SET_EMPTY_STATE_SVGS', () => { + it('updates empty state SVGs', () => { + mutations.SET_EMPTY_STATE_SVGS(localState, { + emptyStateSvgPath: 'emptyState', + noChangesStateSvgPath: 'noChanges', + committedStateSvgPath: 'commited', + }); + + expect(localState.emptyStateSvgPath).toBe('emptyState'); + expect(localState.noChangesStateSvgPath).toBe('noChanges'); + expect(localState.committedStateSvgPath).toBe('commited'); + }); + }); + describe('UPDATE_TEMP_FLAG', () => { beforeEach(() => { localState.entries.test = { @@ -116,4 +138,14 @@ describe('Multi-file store mutations', () => { expect(localState.fileFindVisible).toBe(true); }); }); + + describe('BURST_UNUSED_SEAL', () => { + it('updates unusedSeal', () => { + expect(localState.unusedSeal).toBe(true); + + mutations.BURST_UNUSED_SEAL(localState); + + expect(localState.unusedSeal).toBe(false); + }); + }); }); diff --git a/spec/javascripts/lib/utils/text_utility_spec.js b/spec/javascripts/lib/utils/text_utility_spec.js index ae00fb76714..eab5c24406a 100644 --- a/spec/javascripts/lib/utils/text_utility_spec.js +++ b/spec/javascripts/lib/utils/text_utility_spec.js @@ -75,6 +75,14 @@ describe('text_utility', () => { 'This is a text with html .', ); }); + + it('passes through with null string input', () => { + expect(textUtils.stripHtml(null, ' ')).toEqual(null); + }); + + it('passes through with undefined string input', () => { + expect(textUtils.stripHtml(undefined, ' ')).toEqual(undefined); + }); }); describe('convertToCamelCase', () => { diff --git a/spec/javascripts/monitoring/graph/flag_spec.js b/spec/javascripts/monitoring/graph/flag_spec.js index 2d474e9092f..19278312b6d 100644 --- a/spec/javascripts/monitoring/graph/flag_spec.js +++ b/spec/javascripts/monitoring/graph/flag_spec.js @@ -22,15 +22,20 @@ const defaultValuesComponent = { graphHeightOffset: 120, showFlagContent: true, realPixelRatio: 1, - timeSeries: [{ - values: [{ - time: new Date('2017-06-04T18:17:33.501Z'), - value: '1.49609375', - }], - }], + timeSeries: [ + { + values: [ + { + time: new Date('2017-06-04T18:17:33.501Z'), + value: '1.49609375', + }, + ], + }, + ], unitOfDisplay: 'ms', currentDataIndex: 0, legendTitle: 'Average', + currentCoordinates: [], }; const deploymentFlagData = { @@ -113,7 +118,7 @@ describe('GraphFlag', () => { }); it('formatDate', () => { - expect(component.formatDate).toEqual('Sun, Jun 4'); + expect(component.formatDate).toEqual('04 Jun 2017, '); }); it('cursorStyle', () => { diff --git a/spec/javascripts/monitoring/graph/track_line_spec.js b/spec/javascripts/monitoring/graph/track_line_spec.js index 45106830a67..27602a861eb 100644 --- a/spec/javascripts/monitoring/graph/track_line_spec.js +++ b/spec/javascripts/monitoring/graph/track_line_spec.js @@ -39,14 +39,14 @@ describe('TrackLine component', () => { const svgEl = vm.$el.querySelector('svg'); const lineEl = vm.$el.querySelector('svg line'); - expect(svgEl.getAttribute('width')).toEqual('15'); - expect(svgEl.getAttribute('height')).toEqual('6'); + expect(svgEl.getAttribute('width')).toEqual('16'); + expect(svgEl.getAttribute('height')).toEqual('8'); expect(lineEl.getAttribute('stroke-width')).toEqual('4'); expect(lineEl.getAttribute('x1')).toEqual('0'); - expect(lineEl.getAttribute('x2')).toEqual('15'); - expect(lineEl.getAttribute('y1')).toEqual('2'); - expect(lineEl.getAttribute('y2')).toEqual('2'); + expect(lineEl.getAttribute('x2')).toEqual('16'); + expect(lineEl.getAttribute('y1')).toEqual('4'); + expect(lineEl.getAttribute('y2')).toEqual('4'); }); }); }); diff --git a/spec/javascripts/monitoring/graph_path_spec.js b/spec/javascripts/monitoring/graph_path_spec.js index c83bd19345f..2515e2ad897 100644 --- a/spec/javascripts/monitoring/graph_path_spec.js +++ b/spec/javascripts/monitoring/graph_path_spec.js @@ -23,6 +23,7 @@ describe('Monitoring Paths', () => { generatedAreaPath: firstTimeSeries.areaPath, lineColor: firstTimeSeries.lineColor, areaColor: firstTimeSeries.areaColor, + showDot: false, }); const metricArea = component.$el.querySelector('.metric-area'); const metricLine = component.$el.querySelector('.metric-line'); @@ -40,6 +41,7 @@ describe('Monitoring Paths', () => { generatedAreaPath: firstTimeSeries.areaPath, lineColor: firstTimeSeries.lineColor, areaColor: firstTimeSeries.areaColor, + showDot: false, }); component.lineStyle = 'dashed'; diff --git a/spec/javascripts/monitoring/graph_spec.js b/spec/javascripts/monitoring/graph_spec.js index 1213c80ba3a..220228e5c08 100644 --- a/spec/javascripts/monitoring/graph_spec.js +++ b/spec/javascripts/monitoring/graph_spec.js @@ -30,7 +30,6 @@ describe('Graph', () => { it('has a title', () => { const component = createComponent({ graphData: convertedMetrics[1], - classType: 'col-md-6', updateAspectRatio: false, deploymentData, tagsPath, @@ -46,7 +45,6 @@ describe('Graph', () => { it('axisTransform translates an element Y position depending of its height', () => { const component = createComponent({ graphData: convertedMetrics[1], - classType: 'col-md-6', updateAspectRatio: false, deploymentData, tagsPath, @@ -62,7 +60,6 @@ describe('Graph', () => { it('outerViewBox gets a width and height property based on the DOM size of the element', () => { const component = createComponent({ graphData: convertedMetrics[1], - classType: 'col-md-6', updateAspectRatio: false, deploymentData, tagsPath, @@ -79,7 +76,6 @@ describe('Graph', () => { it('sends an event to the eventhub when it has finished resizing', done => { const component = createComponent({ graphData: convertedMetrics[1], - classType: 'col-md-6', updateAspectRatio: false, deploymentData, tagsPath, @@ -97,7 +93,6 @@ describe('Graph', () => { it('has a title for the y-axis and the chart legend that comes from the backend', () => { const component = createComponent({ graphData: convertedMetrics[1], - classType: 'col-md-6', updateAspectRatio: false, deploymentData, tagsPath, @@ -111,7 +106,6 @@ describe('Graph', () => { it('sets the currentData object based on the hovered data index', () => { const component = createComponent({ graphData: convertedMetrics[1], - classType: 'col-md-6', updateAspectRatio: false, deploymentData, graphIdentifier: 0, @@ -125,6 +119,5 @@ describe('Graph', () => { component.positionFlag(); expect(component.currentData).toBe(component.timeSeries[0].values[10]); - expect(component.currentDataIndex).toEqual(10); }); }); diff --git a/spec/javascripts/pipelines/graph/action_component_spec.js b/spec/javascripts/pipelines/graph/action_component_spec.js index 3de10392472..d646bef96f5 100644 --- a/spec/javascripts/pipelines/graph/action_component_spec.js +++ b/spec/javascripts/pipelines/graph/action_component_spec.js @@ -22,7 +22,7 @@ describe('pipeline graph action component', () => { }); it('should emit an event with the provided link', () => { - eventHub.$on('graphAction', link => { + eventHub.$on('postAction', link => { expect(link).toEqual('foo'); }); }); diff --git a/spec/javascripts/pipelines/mock_data.js b/spec/javascripts/pipelines/mock_data.js index 59092e0f041..a5a200973d7 100644 --- a/spec/javascripts/pipelines/mock_data.js +++ b/spec/javascripts/pipelines/mock_data.js @@ -321,6 +321,103 @@ export const pipelineWithStages = { }; export const stageReply = { - html: - '\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="karma - failed \u0026lt;br\u0026gt; (script failure)" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62402048"\u003e\u003cspan class="ci-status-icon ci-status-icon-failed"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_failed"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003ekarma\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62402048/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="codequality - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398081"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003ecodequality\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398081/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="db:check-schema-pg - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398066"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003edb:check-schema-pg\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398066/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="db:migrate:reset-mysql - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398065"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003edb:migrate:reset-mysql\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398065/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="db:migrate:reset-pg - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398064"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003edb:migrate:reset-pg\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398064/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="db:rollback-mysql - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398070"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003edb:rollback-mysql\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398070/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="db:rollback-pg - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398069"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003edb:rollback-pg\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398069/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="dependency_scanning - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398083"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003edependency_scanning\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398083/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="docs lint - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398061"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003edocs lint\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398061/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="downtime_check - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398062"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003edowntime_check\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398062/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="ee_compat_check - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398063"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003eee_compat_check\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398063/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="gitlab:assets:compile - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398075"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003egitlab:assets:compile\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398075/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="gitlab:setup-mysql - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398073"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003egitlab:setup-mysql\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398073/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="gitlab:setup-pg - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398071"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003egitlab:setup-pg\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398071/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="gitlab_git_test - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398086"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003egitlab_git_test\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398086/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="migration:path-mysql - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398068"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003emigration:path-mysql\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398068/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="migration:path-pg - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398067"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003emigration:path-pg\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398067/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="qa:internal - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398084"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003eqa:internal\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398084/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="qa:selectors - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398085"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003eqa:selectors\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398085/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 0 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398020"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 0 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398020/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 1 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398022"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 1 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398022/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 10 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398033"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 10 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398033/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 11 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398034"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 11 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398034/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 12 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398035"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 12 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398035/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 13 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398036"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 13 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398036/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 14 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398037"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 14 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398037/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 15 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398038"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 15 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398038/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 16 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398039"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 16 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398039/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 17 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398040"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 17 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398040/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 18 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398041"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 18 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398041/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 19 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398042"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 19 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398042/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 2 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398024"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 2 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398024/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 20 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398043"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 20 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398043/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 21 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398044"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 21 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398044/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 22 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398046"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 22 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398046/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 23 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398047"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 23 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398047/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 24 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398048"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 24 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398048/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 25 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398049"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 25 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398049/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 26 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398050"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 26 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398050/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 27 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398051"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 27 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398051/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 3 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398025"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 3 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398025/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 4 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398027"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 4 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398027/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 5 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398028"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 5 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398028/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 6 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398029"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 6 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398029/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 7 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398030"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 7 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398030/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 8 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398031"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 8 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398031/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 9 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398032"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 9 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398032/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 0 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62397981"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 0 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62397981/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 1 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62397985"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 1 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62397985/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 10 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398000"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 10 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398000/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 11 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398001"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 11 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398001/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 12 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398002"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 12 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398002/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 13 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398003"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 13 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398003/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 14 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398004"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 14 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398004/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 15 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398006"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 15 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398006/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 16 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398007"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 16 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398007/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 17 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398008"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 17 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398008/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 18 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398009"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 18 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398009/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 19 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398010"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 19 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398010/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 2 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62397986"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 2 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62397986/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 20 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398012"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 20 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398012/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 21 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398013"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 21 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398013/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 22 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398014"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 22 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398014/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 23 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398015"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 23 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398015/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 24 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398016"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 24 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398016/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 25 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398017"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 25 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398017/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 26 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398018"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 26 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398018/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 27 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398019"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 27 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398019/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 3 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62397988"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 3 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62397988/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 4 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62397989"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 4 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62397989/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 5 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62397991"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 5 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62397991/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 6 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62397993"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 6 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62397993/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 7 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62397994"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 7 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62397994/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 8 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62397995"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 8 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62397995/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 9 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62397996"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 9 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62397996/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="sast - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398082"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003esast\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398082/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="spinach-mysql 0 2 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398058"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003espinach-mysql 0 2\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398058/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="spinach-mysql 1 2 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398059"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003espinach-mysql 1 2\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398059/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="spinach-pg 0 2 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398053"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003espinach-pg 0 2\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398053/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="spinach-pg 1 2 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398056"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003espinach-pg 1 2\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398056/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="static-analysis - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398060"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003estatic-analysis\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398060/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n', + name: 'deploy', + title: 'deploy: running', + latest_statuses: [ + { + id: 928, + name: 'stop staging', + started: false, + build_path: '/twitter/flight/-/jobs/928', + cancel_path: '/twitter/flight/-/jobs/928/cancel', + playable: false, + created_at: '2018-04-04T20:02:02.728Z', + updated_at: '2018-04-04T20:02:02.766Z', + status: { + icon: 'status_pending', + text: 'pending', + label: 'pending', + group: 'pending', + tooltip: 'pending', + has_details: true, + details_path: '/twitter/flight/-/jobs/928', + favicon: + '/assets/ci_favicons/dev/favicon_status_pending-db32e1faf94b9f89530ac519790920d1f18ea8f6af6cd2e0a26cd6840cacf101.ico', + action: { + icon: 'cancel', + title: 'Cancel', + path: '/twitter/flight/-/jobs/928/cancel', + method: 'post', + }, + }, + }, + { + id: 926, + name: 'production', + started: false, + build_path: '/twitter/flight/-/jobs/926', + retry_path: '/twitter/flight/-/jobs/926/retry', + play_path: '/twitter/flight/-/jobs/926/play', + playable: true, + created_at: '2018-04-04T20:00:57.202Z', + updated_at: '2018-04-04T20:11:13.110Z', + status: { + icon: 'status_canceled', + text: 'canceled', + label: 'manual play action', + group: 'canceled', + tooltip: 'canceled', + has_details: true, + details_path: '/twitter/flight/-/jobs/926', + favicon: + '/assets/ci_favicons/dev/favicon_status_canceled-5491840b9b6feafba0bc599cbd49ee9580321dc809683856cf1b0d51532b1af6.ico', + action: { + icon: 'play', + title: 'Play', + path: '/twitter/flight/-/jobs/926/play', + method: 'post', + }, + }, + }, + { + id: 217, + name: 'staging', + started: '2018-03-07T08:41:46.234Z', + build_path: '/twitter/flight/-/jobs/217', + retry_path: '/twitter/flight/-/jobs/217/retry', + playable: false, + created_at: '2018-03-07T14:41:58.093Z', + updated_at: '2018-03-07T14:41:58.093Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/twitter/flight/-/jobs/217', + favicon: + '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico', + action: { + icon: 'retry', + title: 'Retry', + path: '/twitter/flight/-/jobs/217/retry', + method: 'post', + }, + }, + }, + ], + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + tooltip: 'running', + has_details: true, + details_path: '/twitter/flight/pipelines/13#deploy', + favicon: + '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', + }, + path: '/twitter/flight/pipelines/13#deploy', + dropdown_path: '/twitter/flight/pipelines/13/stage.json?stage=deploy', }; diff --git a/spec/javascripts/pipelines/stage_spec.js b/spec/javascripts/pipelines/stage_spec.js index be1632e7206..75156e7bdfd 100644 --- a/spec/javascripts/pipelines/stage_spec.js +++ b/spec/javascripts/pipelines/stage_spec.js @@ -4,6 +4,7 @@ import axios from '~/lib/utils/axios_utils'; import stage from '~/pipelines/components/stage.vue'; import eventHub from '~/pipelines/event_hub'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { stageReply } from './mock_data'; describe('Pipelines stage component', () => { let StageComponent; @@ -41,7 +42,7 @@ describe('Pipelines stage component', () => { describe('with successfull request', () => { beforeEach(() => { - mock.onGet('path.json').reply(200, { html: 'foo' }); + mock.onGet('path.json').reply(200, stageReply); }); it('should render the received data and emit `clickedDropdown` event', done => { @@ -51,7 +52,7 @@ describe('Pipelines stage component', () => { setTimeout(() => { expect( component.$el.querySelector('.js-builds-dropdown-container ul').textContent.trim(), - ).toEqual('foo'); + ).toContain(stageReply.latest_statuses[0].name); expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown'); done(); }, 0); @@ -74,7 +75,9 @@ describe('Pipelines stage component', () => { describe('update endpoint correctly', () => { beforeEach(() => { - mock.onGet('bar.json').reply(200, { html: 'this is the updated content' }); + const copyStage = Object.assign({}, stageReply); + copyStage.latest_statuses[0].name = 'this is the updated content'; + mock.onGet('bar.json').reply(200, copyStage); }); it('should update the stage to request the new endpoint provided', done => { @@ -93,7 +96,7 @@ describe('Pipelines stage component', () => { setTimeout(() => { expect( component.$el.querySelector('.js-builds-dropdown-container ul').textContent.trim(), - ).toEqual('this is the updated content'); + ).toContain('this is the updated content'); done(); }); }); diff --git a/spec/javascripts/projects_dropdown/components/app_spec.js b/spec/javascripts/projects_dropdown/components/app_spec.js index 2054fef790b..38b31c3d727 100644 --- a/spec/javascripts/projects_dropdown/components/app_spec.js +++ b/spec/javascripts/projects_dropdown/components/app_spec.js @@ -23,17 +23,18 @@ const createComponent = () => { }); }; -const returnServicePromise = (data, failed) => new Promise((resolve, reject) => { - if (failed) { - reject(data); - } else { - resolve({ - json() { - return data; - }, - }); - } -}); +const returnServicePromise = (data, failed) => + new Promise((resolve, reject) => { + if (failed) { + reject(data); + } else { + resolve({ + json() { + return data; + }, + }); + } + }); describe('AppComponent', () => { describe('computed', () => { @@ -185,7 +186,7 @@ describe('AppComponent', () => { describe('fetchSearchedProjects', () => { const searchQuery = 'test'; - it('should perform search with provided search query', (done) => { + it('should perform search with provided search query', done => { const mockData = [mockRawProject]; spyOn(vm, 'toggleLoader'); spyOn(vm, 'toggleSearchProjectsList'); @@ -203,7 +204,7 @@ describe('AppComponent', () => { }, 0); }); - it('should update props for showing search failure', (done) => { + it('should update props for showing search failure', done => { spyOn(vm, 'toggleSearchProjectsList'); spyOn(vm.service, 'getSearchedProjects').and.returnValue(returnServicePromise({}, true)); @@ -219,7 +220,7 @@ describe('AppComponent', () => { }); describe('logCurrentProjectAccess', () => { - it('should log current project access via service', (done) => { + it('should log current project access via service', done => { spyOn(vm.service, 'logProjectAccess'); vm.currentProject = mockProject; @@ -257,7 +258,7 @@ describe('AppComponent', () => { }); describe('created', () => { - it('should bind event listeners on eventHub', (done) => { + it('should bind event listeners on eventHub', done => { spyOn(eventHub, '$on'); createComponent().$mount(); @@ -273,7 +274,7 @@ describe('AppComponent', () => { }); describe('beforeDestroy', () => { - it('should unbind event listeners on eventHub', (done) => { + it('should unbind event listeners on eventHub', done => { const vm = createComponent(); spyOn(eventHub, '$off'); @@ -305,7 +306,7 @@ describe('AppComponent', () => { expect(vm.$el.querySelector('.search-input-container')).toBeDefined(); }); - it('should render loading animation', (done) => { + it('should render loading animation', done => { vm.toggleLoader(true); Vue.nextTick(() => { const loadingEl = vm.$el.querySelector('.loading-animation'); @@ -317,7 +318,7 @@ describe('AppComponent', () => { }); }); - it('should render frequent projects list header', (done) => { + it('should render frequent projects list header', done => { vm.toggleFrequentProjectsList(true); Vue.nextTick(() => { const sectionHeaderEl = vm.$el.querySelector('.section-header'); @@ -328,7 +329,7 @@ describe('AppComponent', () => { }); }); - it('should render frequent projects list', (done) => { + it('should render frequent projects list', done => { vm.toggleFrequentProjectsList(true); Vue.nextTick(() => { expect(vm.$el.querySelector('.projects-list-frequent-container')).toBeDefined(); @@ -336,7 +337,7 @@ describe('AppComponent', () => { }); }); - it('should render searched projects list', (done) => { + it('should render searched projects list', done => { vm.toggleSearchProjectsList(true); Vue.nextTick(() => { expect(vm.$el.querySelector('.section-header')).toBe(null); diff --git a/spec/javascripts/sidebar/participants_spec.js b/spec/javascripts/sidebar/participants_spec.js index 2a3b60c399c..e796ddee62f 100644 --- a/spec/javascripts/sidebar/participants_spec.js +++ b/spec/javascripts/sidebar/participants_spec.js @@ -170,5 +170,19 @@ describe('Participants', function () { expect(vm.isShowingMoreParticipants).toBe(true); }); + + it('clicking on participants icon emits `toggleSidebar` event', () => { + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 2, + }); + spyOn(vm, '$emit'); + + const participantsIconEl = vm.$el.querySelector('.sidebar-collapsed-icon'); + + participantsIconEl.click(); + expect(vm.$emit).toHaveBeenCalledWith('toggleSidebar'); + }); }); }); diff --git a/spec/javascripts/sidebar/sidebar_subscriptions_spec.js b/spec/javascripts/sidebar/sidebar_subscriptions_spec.js index 56a2543660b..9e437084224 100644 --- a/spec/javascripts/sidebar/sidebar_subscriptions_spec.js +++ b/spec/javascripts/sidebar/sidebar_subscriptions_spec.js @@ -3,7 +3,6 @@ import sidebarSubscriptions from '~/sidebar/components/subscriptions/sidebar_sub import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarService from '~/sidebar/services/sidebar_service'; import SidebarStore from '~/sidebar/stores/sidebar_store'; -import eventHub from '~/sidebar/event_hub'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; import Mock from './mock_data'; @@ -32,7 +31,7 @@ describe('Sidebar Subscriptions', function () { mediator, }); - eventHub.$emit('toggleSubscription'); + vm.onToggleSubscription(); expect(mediator.toggleSubscription).toHaveBeenCalled(); }); diff --git a/spec/javascripts/sidebar/subscriptions_spec.js b/spec/javascripts/sidebar/subscriptions_spec.js index aee8f0acbb9..f0a53e573c3 100644 --- a/spec/javascripts/sidebar/subscriptions_spec.js +++ b/spec/javascripts/sidebar/subscriptions_spec.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue'; +import eventHub from '~/sidebar/event_hub'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Subscriptions', function () { @@ -39,4 +40,22 @@ describe('Subscriptions', function () { expect(vm.$refs.toggleButton.$el.querySelector('.project-feature-toggle')).toHaveClass('is-checked'); }); + + it('toggleSubscription method emits `toggleSubscription` event on eventHub and Component', () => { + vm = mountComponent(Subscriptions, { subscribed: true }); + spyOn(eventHub, '$emit'); + spyOn(vm, '$emit'); + + vm.toggleSubscription(); + expect(eventHub.$emit).toHaveBeenCalledWith('toggleSubscription', jasmine.any(Object)); + expect(vm.$emit).toHaveBeenCalledWith('toggleSubscription', jasmine.any(Object)); + }); + + it('onClickCollapsedIcon method emits `toggleSidebar` event on component', () => { + vm = mountComponent(Subscriptions, { subscribed: true }); + spyOn(vm, '$emit'); + + vm.onClickCollapsedIcon(); + expect(vm.$emit).toHaveBeenCalledWith('toggleSidebar'); + }); }); diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js index bcd15f5eae2..2411d33a496 100644 --- a/spec/javascripts/test_bundle.js +++ b/spec/javascripts/test_bundle.js @@ -84,21 +84,11 @@ beforeEach(() => { const axiosDefaultAdapter = getDefaultAdapter(); -let testFiles = process.env.TEST_FILES || []; -if (testFiles.length > 0) { - testFiles = testFiles.map(path => path.replace(/^spec\/javascripts\//, '').replace(/\.js$/, '')); - console.log(`Running only tests matching: ${testFiles}`); -} else { - console.log('Running all tests'); -} - // render all of our tests const testsContext = require.context('.', true, /_spec$/); testsContext.keys().forEach(function(path) { try { - if (testFiles.length === 0 || testFiles.some(p => path.includes(p))) { - testsContext(path); - } + testsContext(path); } catch (err) { console.error('[ERROR] Unable to load spec: ', path); describe('Test bundle', function() { diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_merged_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merged_spec.js index c2c92d8ac56..adeea03481f 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_merged_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merged_spec.js @@ -6,6 +6,14 @@ import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('MRWidgetMerged', () => { let vm; const targetBranch = 'foo'; + const selectors = { + get copyMergeShaButton() { + return vm.$el.querySelector('button.js-mr-merged-copy-sha'); + }, + get mergeCommitShaLink() { + return vm.$el.querySelector('a.js-mr-merged-commit-sha'); + }, + }; beforeEach(() => { const Component = Vue.extend(mergedComponent); @@ -31,6 +39,9 @@ describe('MRWidgetMerged', () => { readableClosedAt: '', }, updatedAt: 'mergedUpdatedAt', + shortMergeCommitSha: 'asdf1234', + mergeCommitPath: 'http://localhost:3000/root/nautilus/commit/f7ce827c314c9340b075657fd61c789fb01cf74d', + sourceBranch: 'bar', targetBranch, }; @@ -140,6 +151,17 @@ describe('MRWidgetMerged', () => { expect(vm.$el.textContent).toContain('Cherry-pick'); }); + it('shows button to copy commit SHA to clipboard', () => { + expect(selectors.copyMergeShaButton).toExist(); + expect(selectors.copyMergeShaButton.getAttribute('data-clipboard-text')).toBe(vm.mr.shortMergeCommitSha); + }); + + it('shows merge commit SHA link', () => { + expect(selectors.mergeCommitShaLink).toExist(); + expect(selectors.mergeCommitShaLink.text).toContain(vm.mr.shortMergeCommitSha); + expect(selectors.mergeCommitShaLink.href).toBe(vm.mr.mergeCommitPath); + }); + it('should not show source branch removed text', (done) => { vm.mr.sourceBranchRemoved = false; diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_wip_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_wip_spec.js index 98ab61a0367..cea603368bf 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_wip_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_wip_spec.js @@ -1,9 +1,9 @@ import Vue from 'vue'; -import wipComponent from '~/vue_merge_request_widget/components/states/mr_widget_wip'; +import WorkInProgress from '~/vue_merge_request_widget/components/states/work_in_progress.vue'; import eventHub from '~/vue_merge_request_widget/event_hub'; const createComponent = () => { - const Component = Vue.extend(wipComponent); + const Component = Vue.extend(WorkInProgress); const mr = { title: 'The best MR ever', removeWIPPath: '/path/to/remove/wip', @@ -17,10 +17,10 @@ const createComponent = () => { }); }; -describe('MRWidgetWIP', () => { +describe('Wip', () => { describe('props', () => { it('should have props', () => { - const { mr, service } = wipComponent.props; + const { mr, service } = WorkInProgress.props; expect(mr.type instanceof Object).toBeTruthy(); expect(mr.required).toBeTruthy(); diff --git a/spec/javascripts/vue_mr_widget/mock_data.js b/spec/javascripts/vue_mr_widget/mock_data.js index 3fc7663b9c2..9d2a15ff009 100644 --- a/spec/javascripts/vue_mr_widget/mock_data.js +++ b/spec/javascripts/vue_mr_widget/mock_data.js @@ -18,6 +18,7 @@ export default { human_total_time_spent: null, in_progress_merge_commit_sha: null, merge_commit_sha: '53027d060246c8f47e4a9310fb332aa52f221775', + short_merge_commit_sha: '53027d06', merge_error: null, merge_params: { force_remove_source_branch: null, @@ -215,4 +216,5 @@ export default { diverged_commits_count: 0, only_allow_merge_if_pipeline_succeeds: false, commit_change_content_path: '/root/acets-app/merge_requests/22/commit_change_content', + merge_commit_path: 'http://localhost:3000/root/acets-app/commit/53027d060246c8f47e4a9310fb332aa52f221775', }; diff --git a/spec/lib/backup/repository_spec.rb b/spec/lib/backup/repository_spec.rb index b3777be312b..b1ea9c0b622 100644 --- a/spec/lib/backup/repository_spec.rb +++ b/spec/lib/backup/repository_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Backup::Repository do let(:progress) { StringIO.new } - let!(:project) { create(:project) } + let!(:project) { create(:project, :wiki_repo) } before do allow(progress).to receive(:puts) @@ -102,7 +102,7 @@ describe Backup::Repository do it 'invalidates the emptiness cache' do expect(wiki.repository).to receive(:expire_emptiness_caches).once - wiki.empty? + described_class.new.send(:empty_repo?, wiki) end context 'wiki repo has content' do diff --git a/spec/lib/gitlab/background_migration/migrate_stage_index_spec.rb b/spec/lib/gitlab/background_migration/migrate_stage_index_spec.rb new file mode 100644 index 00000000000..f8107dd40b9 --- /dev/null +++ b/spec/lib/gitlab/background_migration/migrate_stage_index_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +describe Gitlab::BackgroundMigration::MigrateStageIndex, :migration, schema: 20180420080616 do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:pipelines) { table(:ci_pipelines) } + let(:stages) { table(:ci_stages) } + let(:jobs) { table(:ci_builds) } + + before do + namespaces.create(id: 10, name: 'gitlab-org', path: 'gitlab-org') + projects.create!(id: 11, namespace_id: 10, name: 'gitlab', path: 'gitlab') + pipelines.create!(id: 12, project_id: 11, ref: 'master', sha: 'adf43c3a') + + stages.create(id: 100, project_id: 11, pipeline_id: 12, name: 'build') + stages.create(id: 101, project_id: 11, pipeline_id: 12, name: 'test') + + jobs.create!(id: 121, commit_id: 12, project_id: 11, + stage_idx: 2, stage_id: 100) + jobs.create!(id: 122, commit_id: 12, project_id: 11, + stage_idx: 2, stage_id: 100) + jobs.create!(id: 123, commit_id: 12, project_id: 11, + stage_idx: 10, stage_id: 100) + jobs.create!(id: 124, commit_id: 12, project_id: 11, + stage_idx: 3, stage_id: 101) + end + + it 'correctly migrates stages indices' do + expect(stages.all.pluck(:position)).to all(be_nil) + + described_class.new.perform(100, 101) + + expect(stages.all.pluck(:position)).to eq [2, 3] + end +end diff --git a/spec/lib/gitlab/background_migration/populate_import_state_spec.rb b/spec/lib/gitlab/background_migration/populate_import_state_spec.rb new file mode 100644 index 00000000000..f9952ee5163 --- /dev/null +++ b/spec/lib/gitlab/background_migration/populate_import_state_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe Gitlab::BackgroundMigration::PopulateImportState, :migration, schema: 20180502134117 do + let(:migration) { described_class.new } + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:import_state) { table(:project_mirror_data) } + + before do + namespaces.create(id: 1, name: 'gitlab-org', path: 'gitlab-org') + + projects.create!(id: 1, namespace_id: 1, name: 'gitlab1', + path: 'gitlab1', import_error: "foo", import_status: :started, + import_url: generate(:url)) + projects.create!(id: 2, namespace_id: 1, name: 'gitlab2', path: 'gitlab2', + import_status: :none, import_url: generate(:url)) + projects.create!(id: 3, namespace_id: 1, name: 'gitlab3', + path: 'gitlab3', import_error: "bar", import_status: :failed, + import_url: generate(:url)) + + allow(BackgroundMigrationWorker).to receive(:perform_in) + end + + it "creates new import_state records with project's import data" do + expect(projects.where.not(import_status: :none).count).to eq(2) + + expect do + migration.perform(1, 3) + end.to change { import_state.all.count }.from(0).to(2) + + expect(import_state.first.last_error).to eq("foo") + expect(import_state.last.last_error).to eq("bar") + expect(import_state.first.status).to eq("started") + expect(import_state.last.status).to eq("failed") + expect(projects.first.import_status).to eq("none") + expect(projects.last.import_status).to eq("none") + end +end diff --git a/spec/lib/gitlab/background_migration/rollback_import_state_data_spec.rb b/spec/lib/gitlab/background_migration/rollback_import_state_data_spec.rb new file mode 100644 index 00000000000..9f8c3bc220f --- /dev/null +++ b/spec/lib/gitlab/background_migration/rollback_import_state_data_spec.rb @@ -0,0 +1,28 @@ +require 'spec_helper' + +describe Gitlab::BackgroundMigration::RollbackImportStateData, :migration, schema: 20180502134117 do + let(:migration) { described_class.new } + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:import_state) { table(:project_mirror_data) } + + before do + namespaces.create(id: 1, name: 'gitlab-org', path: 'gitlab-org') + + projects.create!(id: 1, namespace_id: 1, name: 'gitlab1', import_url: generate(:url)) + projects.create!(id: 2, namespace_id: 1, name: 'gitlab2', path: 'gitlab2', import_url: generate(:url)) + + import_state.create!(id: 1, project_id: 1, status: :started, last_error: "foo") + import_state.create!(id: 2, project_id: 2, status: :failed) + + allow(BackgroundMigrationWorker).to receive(:perform_in) + end + + it "creates new import_state records with project's import data" do + migration.perform(1, 2) + + expect(projects.first.import_status).to eq("started") + expect(projects.second.import_status).to eq("failed") + expect(projects.first.import_error).to eq("foo") + end +end diff --git a/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb index 3ae7053a995..85d73e5c382 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb @@ -5,6 +5,10 @@ describe Gitlab::Ci::Pipeline::Chain::Build do set(:user) { create(:user) } let(:pipeline) { Ci::Pipeline.new } + let(:variables_attributes) do + [{ key: 'first', secret_value: 'world' }, + { key: 'second', secret_value: 'second_world' }] + end let(:command) do Gitlab::Ci::Pipeline::Chain::Command.new( source: :push, @@ -15,7 +19,8 @@ describe Gitlab::Ci::Pipeline::Chain::Build do trigger_request: nil, schedule: nil, project: project, - current_user: user) + current_user: user, + variables_attributes: variables_attributes) end let(:step) { described_class.new(pipeline, command) } @@ -39,6 +44,8 @@ describe Gitlab::Ci::Pipeline::Chain::Build do expect(pipeline.tag).to be false expect(pipeline.user).to eq user expect(pipeline.project).to eq project + expect(pipeline.variables.map { |var| var.slice(:key, :secret_value) }) + .to eq variables_attributes.map(&:with_indifferent_access) end it 'sets a valid config source' do diff --git a/spec/lib/gitlab/ci/pipeline/chain/create_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/create_spec.rb index dc12ba076bc..0edc3f315bb 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/create_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/create_spec.rb @@ -17,7 +17,7 @@ describe Gitlab::Ci::Pipeline::Chain::Create do context 'when pipeline is ready to be saved' do before do - pipeline.stages.build(name: 'test', project: project) + pipeline.stages.build(name: 'test', position: 0, project: project) step.perform! end diff --git a/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb index eb1b285c7bd..05ce3412fd8 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb @@ -24,7 +24,8 @@ describe Gitlab::Ci::Pipeline::Seed::Stage do describe '#attributes' do it 'returns hash attributes of a stage' do expect(subject.attributes).to be_a Hash - expect(subject.attributes).to include(:name, :project) + expect(subject.attributes) + .to include(:name, :position, :pipeline, :project) end end diff --git a/spec/lib/gitlab/ci/trace/chunked_io_spec.rb b/spec/lib/gitlab/ci/trace/chunked_io_spec.rb new file mode 100644 index 00000000000..6259b952add --- /dev/null +++ b/spec/lib/gitlab/ci/trace/chunked_io_spec.rb @@ -0,0 +1,383 @@ +require 'spec_helper' + +describe Gitlab::Ci::Trace::ChunkedIO, :clean_gitlab_redis_cache do + include ChunkedIOHelpers + + set(:build) { create(:ci_build, :running) } + let(:chunked_io) { described_class.new(build) } + + before do + stub_feature_flags(ci_enable_live_trace: true) + end + + context "#initialize" do + context 'when a chunk exists' do + before do + build.trace.set('ABC') + end + + it { expect(chunked_io.size).to eq(3) } + end + + context 'when two chunks exist' do + before do + stub_buffer_size(4) + build.trace.set('ABCDEF') + end + + it { expect(chunked_io.size).to eq(6) } + end + + context 'when no chunks exists' do + it { expect(chunked_io.size).to eq(0) } + end + end + + context "#seek" do + subject { chunked_io.seek(pos, where) } + + before do + build.trace.set(sample_trace_raw) + end + + context 'when moves pos to end of the file' do + let(:pos) { 0 } + let(:where) { IO::SEEK_END } + + it { is_expected.to eq(sample_trace_raw.bytesize) } + end + + context 'when moves pos to middle of the file' do + let(:pos) { sample_trace_raw.bytesize / 2 } + let(:where) { IO::SEEK_SET } + + it { is_expected.to eq(pos) } + end + + context 'when moves pos around' do + it 'matches the result' do + expect(chunked_io.seek(0)).to eq(0) + expect(chunked_io.seek(100, IO::SEEK_CUR)).to eq(100) + expect { chunked_io.seek(sample_trace_raw.bytesize + 1, IO::SEEK_CUR) } + .to raise_error('new position is outside of file') + end + end + end + + context "#eof?" do + subject { chunked_io.eof? } + + before do + build.trace.set(sample_trace_raw) + end + + context 'when current pos is at end of the file' do + before do + chunked_io.seek(sample_trace_raw.bytesize, IO::SEEK_SET) + end + + it { is_expected.to be_truthy } + end + + context 'when current pos is not at end of the file' do + before do + chunked_io.seek(0, IO::SEEK_SET) + end + + it { is_expected.to be_falsey } + end + end + + context "#each_line" do + let(:string_io) { StringIO.new(sample_trace_raw) } + + context 'when buffer size is smaller than file size' do + before do + stub_buffer_size(sample_trace_raw.bytesize / 2) + build.trace.set(sample_trace_raw) + end + + it 'yields lines' do + expect { |b| chunked_io.each_line(&b) } + .to yield_successive_args(*string_io.each_line.to_a) + end + end + + context 'when buffer size is larger than file size' do + before do + stub_buffer_size(sample_trace_raw.bytesize * 2) + build.trace.set(sample_trace_raw) + end + + it 'calls get_chunk only once' do + expect_any_instance_of(Gitlab::Ci::Trace::ChunkedIO) + .to receive(:current_chunk).once.and_call_original + + chunked_io.each_line { |line| } + end + end + end + + context "#read" do + subject { chunked_io.read(length) } + + context 'when read the whole size' do + let(:length) { nil } + + context 'when buffer size is smaller than file size' do + before do + stub_buffer_size(sample_trace_raw.bytesize / 2) + build.trace.set(sample_trace_raw) + end + + it { is_expected.to eq(sample_trace_raw) } + end + + context 'when buffer size is larger than file size' do + before do + stub_buffer_size(sample_trace_raw.bytesize * 2) + build.trace.set(sample_trace_raw) + end + + it { is_expected.to eq(sample_trace_raw) } + end + end + + context 'when read only first 100 bytes' do + let(:length) { 100 } + + context 'when buffer size is smaller than file size' do + before do + stub_buffer_size(sample_trace_raw.bytesize / 2) + build.trace.set(sample_trace_raw) + end + + it 'reads a trace' do + is_expected.to eq(sample_trace_raw.byteslice(0, length)) + end + end + + context 'when buffer size is larger than file size' do + before do + stub_buffer_size(sample_trace_raw.bytesize * 2) + build.trace.set(sample_trace_raw) + end + + it 'reads a trace' do + is_expected.to eq(sample_trace_raw.byteslice(0, length)) + end + end + end + + context 'when tries to read oversize' do + let(:length) { sample_trace_raw.bytesize + 1000 } + + context 'when buffer size is smaller than file size' do + before do + stub_buffer_size(sample_trace_raw.bytesize / 2) + build.trace.set(sample_trace_raw) + end + + it 'reads a trace' do + is_expected.to eq(sample_trace_raw) + end + end + + context 'when buffer size is larger than file size' do + before do + stub_buffer_size(sample_trace_raw.bytesize * 2) + build.trace.set(sample_trace_raw) + end + + it 'reads a trace' do + is_expected.to eq(sample_trace_raw) + end + end + end + + context 'when tries to read 0 bytes' do + let(:length) { 0 } + + context 'when buffer size is smaller than file size' do + before do + stub_buffer_size(sample_trace_raw.bytesize / 2) + build.trace.set(sample_trace_raw) + end + + it 'reads a trace' do + is_expected.to be_empty + end + end + + context 'when buffer size is larger than file size' do + before do + stub_buffer_size(sample_trace_raw.bytesize * 2) + build.trace.set(sample_trace_raw) + end + + it 'reads a trace' do + is_expected.to be_empty + end + end + end + end + + context "#readline" do + subject { chunked_io.readline } + + let(:string_io) { StringIO.new(sample_trace_raw) } + + shared_examples 'all line matching' do + it do + (0...sample_trace_raw.lines.count).each do + expect(chunked_io.readline).to eq(string_io.readline) + end + end + end + + context 'when buffer size is smaller than file size' do + before do + stub_buffer_size(sample_trace_raw.bytesize / 2) + build.trace.set(sample_trace_raw) + end + + it_behaves_like 'all line matching' + end + + context 'when buffer size is larger than file size' do + before do + stub_buffer_size(sample_trace_raw.bytesize * 2) + build.trace.set(sample_trace_raw) + end + + it_behaves_like 'all line matching' + end + + context 'when pos is at middle of the file' do + before do + stub_buffer_size(sample_trace_raw.bytesize / 2) + build.trace.set(sample_trace_raw) + + chunked_io.seek(chunked_io.size / 2) + string_io.seek(string_io.size / 2) + end + + it 'reads from pos' do + expect(chunked_io.readline).to eq(string_io.readline) + end + end + end + + context "#write" do + subject { chunked_io.write(data) } + + let(:data) { sample_trace_raw } + + context 'when data does not exist' do + shared_examples 'writes a trace' do + it do + is_expected.to eq(data.bytesize) + + chunked_io.seek(0, IO::SEEK_SET) + expect(chunked_io.read).to eq(data) + end + end + + context 'when buffer size is smaller than file size' do + before do + stub_buffer_size(data.bytesize / 2) + end + + it_behaves_like 'writes a trace' + end + + context 'when buffer size is larger than file size' do + before do + stub_buffer_size(data.bytesize * 2) + end + + it_behaves_like 'writes a trace' + end + end + + context 'when data already exists' do + let(:exist_data) { 'exist data' } + + shared_examples 'appends a trace' do + it do + chunked_io.seek(0, IO::SEEK_END) + is_expected.to eq(data.bytesize) + + chunked_io.seek(0, IO::SEEK_SET) + expect(chunked_io.read).to eq(exist_data + data) + end + end + + context 'when buffer size is smaller than file size' do + before do + stub_buffer_size(sample_trace_raw.bytesize / 2) + build.trace.set(exist_data) + end + + it_behaves_like 'appends a trace' + end + + context 'when buffer size is larger than file size' do + before do + stub_buffer_size(sample_trace_raw.bytesize * 2) + build.trace.set(exist_data) + end + + it_behaves_like 'appends a trace' + end + end + end + + context "#truncate" do + let(:offset) { 10 } + + context 'when data does not exist' do + shared_examples 'truncates a trace' do + it do + chunked_io.truncate(offset) + + chunked_io.seek(0, IO::SEEK_SET) + expect(chunked_io.read).to eq(sample_trace_raw.byteslice(0, offset)) + end + end + + context 'when buffer size is smaller than file size' do + before do + stub_buffer_size(sample_trace_raw.bytesize / 2) + build.trace.set(sample_trace_raw) + end + + it_behaves_like 'truncates a trace' + end + + context 'when buffer size is larger than file size' do + before do + stub_buffer_size(sample_trace_raw.bytesize * 2) + build.trace.set(sample_trace_raw) + end + + it_behaves_like 'truncates a trace' + end + end + end + + context "#destroy!" do + subject { chunked_io.destroy! } + + before do + build.trace.set(sample_trace_raw) + end + + it 'deletes' do + expect { subject }.to change { chunked_io.size } + .from(sample_trace_raw.bytesize).to(0) + + expect(Ci::BuildTraceChunk.where(build: build).count).to eq(0) + end + end +end diff --git a/spec/lib/gitlab/ci/trace/stream_spec.rb b/spec/lib/gitlab/ci/trace/stream_spec.rb index e5555546fa8..4f49958dd33 100644 --- a/spec/lib/gitlab/ci/trace/stream_spec.rb +++ b/spec/lib/gitlab/ci/trace/stream_spec.rb @@ -1,6 +1,12 @@ require 'spec_helper' -describe Gitlab::Ci::Trace::Stream do +describe Gitlab::Ci::Trace::Stream, :clean_gitlab_redis_cache do + set(:build) { create(:ci_build, :running) } + + before do + stub_feature_flags(ci_enable_live_trace: true) + end + describe 'delegates' do subject { described_class.new { nil } } @@ -11,337 +17,470 @@ describe Gitlab::Ci::Trace::Stream do it { is_expected.to delegate_method(:path).to(:stream) } it { is_expected.to delegate_method(:truncate).to(:stream) } it { is_expected.to delegate_method(:valid?).to(:stream).as(:present?) } - it { is_expected.to delegate_method(:file?).to(:path).as(:present?) } end describe '#limit' do - let(:stream) do - described_class.new do - StringIO.new((1..8).to_a.join("\n")) + shared_examples_for 'limits' do + it 'if size is larger we start from beginning' do + stream.limit(20) + + expect(stream.tell).to eq(0) end - end - it 'if size is larger we start from beginning' do - stream.limit(20) + it 'if size is smaller we start from the end' do + stream.limit(2) - expect(stream.tell).to eq(0) - end + expect(stream.raw).to eq("8") + end - it 'if size is smaller we start from the end' do - stream.limit(2) + context 'when the trace contains ANSI sequence and Unicode' do + let(:stream) do + described_class.new do + File.open(expand_fixture_path('trace/ansi-sequence-and-unicode')) + end + end - expect(stream.raw).to eq("8") - end + it 'forwards to the next linefeed, case 1' do + stream.limit(7) - context 'when the trace contains ANSI sequence and Unicode' do - let(:stream) do - described_class.new do - File.open(expand_fixture_path('trace/ansi-sequence-and-unicode')) + result = stream.raw + + expect(result).to eq('') + expect(result.encoding).to eq(Encoding.default_external) end - end - it 'forwards to the next linefeed, case 1' do - stream.limit(7) + it 'forwards to the next linefeed, case 2' do + stream.limit(29) - result = stream.raw + result = stream.raw - expect(result).to eq('') - expect(result.encoding).to eq(Encoding.default_external) - end + expect(result).to eq("\e[01;32m許功蓋\e[0m\n") + expect(result.encoding).to eq(Encoding.default_external) + end - it 'forwards to the next linefeed, case 2' do - stream.limit(29) + # See https://gitlab.com/gitlab-org/gitlab-ce/issues/30796 + it 'reads in binary, output as Encoding.default_external' do + stream.limit(52) - result = stream.raw + result = stream.html - expect(result).to eq("\e[01;32m許功蓋\e[0m\n") - expect(result.encoding).to eq(Encoding.default_external) + expect(result).to eq("ヾ(´༎ຶД༎ຶ`)ノ<br><span class=\"term-fg-green\">許功蓋</span><br>") + expect(result.encoding).to eq(Encoding.default_external) + end end + end - # See https://gitlab.com/gitlab-org/gitlab-ce/issues/30796 - it 'reads in binary, output as Encoding.default_external' do - stream.limit(52) + context 'when stream is StringIO' do + let(:stream) do + described_class.new do + StringIO.new((1..8).to_a.join("\n")) + end + end - result = stream.html + it_behaves_like 'limits' + end - expect(result).to eq("ヾ(´༎ຶД༎ຶ`)ノ<br><span class=\"term-fg-green\">許功蓋</span><br>") - expect(result.encoding).to eq(Encoding.default_external) + context 'when stream is ChunkedIO' do + let(:stream) do + described_class.new do + Gitlab::Ci::Trace::ChunkedIO.new(build).tap do |chunked_io| + chunked_io.write((1..8).to_a.join("\n")) + chunked_io.seek(0, IO::SEEK_SET) + end + end end + + it_behaves_like 'limits' end end describe '#append' do - let(:tempfile) { Tempfile.new } + shared_examples_for 'appends' do + it "truncates and append content" do + stream.append("89", 4) + stream.seek(0) - let(:stream) do - described_class.new do - tempfile.write("12345678") - tempfile.rewind - tempfile + expect(stream.size).to eq(6) + expect(stream.raw).to eq("123489") end - end - after do - tempfile.unlink - end + it 'appends in binary mode' do + '😺'.force_encoding('ASCII-8BIT').each_char.with_index do |byte, offset| + stream.append(byte, offset) + end - it "truncates and append content" do - stream.append("89", 4) - stream.seek(0) + stream.seek(0) - expect(stream.size).to eq(6) - expect(stream.raw).to eq("123489") + expect(stream.size).to eq(4) + expect(stream.raw).to eq('😺') + end end - it 'appends in binary mode' do - '😺'.force_encoding('ASCII-8BIT').each_char.with_index do |byte, offset| - stream.append(byte, offset) + context 'when stream is Tempfile' do + let(:tempfile) { Tempfile.new } + + let(:stream) do + described_class.new do + tempfile.write("12345678") + tempfile.rewind + tempfile + end + end + + after do + tempfile.unlink end - stream.seek(0) + it_behaves_like 'appends' + end - expect(stream.size).to eq(4) - expect(stream.raw).to eq('😺') + context 'when stream is ChunkedIO' do + let(:stream) do + described_class.new do + Gitlab::Ci::Trace::ChunkedIO.new(build).tap do |chunked_io| + chunked_io.write('12345678') + chunked_io.seek(0, IO::SEEK_SET) + end + end + end + + it_behaves_like 'appends' end end describe '#set' do - let(:stream) do - described_class.new do - StringIO.new("12345678") + shared_examples_for 'sets' do + before do + stream.set("8901") + end + + it "overwrite content" do + stream.seek(0) + + expect(stream.size).to eq(4) + expect(stream.raw).to eq("8901") end end - before do - stream.set("8901") + context 'when stream is StringIO' do + let(:stream) do + described_class.new do + StringIO.new("12345678") + end + end + + it_behaves_like 'sets' end - it "overwrite content" do - stream.seek(0) + context 'when stream is ChunkedIO' do + let(:stream) do + described_class.new do + Gitlab::Ci::Trace::ChunkedIO.new(build).tap do |chunked_io| + chunked_io.write('12345678') + chunked_io.seek(0, IO::SEEK_SET) + end + end + end - expect(stream.size).to eq(4) - expect(stream.raw).to eq("8901") + it_behaves_like 'sets' end end describe '#raw' do - let(:path) { __FILE__ } - let(:lines) { File.readlines(path) } - let(:stream) do - described_class.new do - File.open(path) + shared_examples_for 'sets' do + it 'returns all contents if last_lines is not specified' do + result = stream.raw + + expect(result).to eq(lines.join) + expect(result.encoding).to eq(Encoding.default_external) end - end - it 'returns all contents if last_lines is not specified' do - result = stream.raw + context 'limit max lines' do + before do + # specifying BUFFER_SIZE forces to seek backwards + allow(described_class).to receive(:BUFFER_SIZE) + .and_return(2) + end - expect(result).to eq(lines.join) - expect(result.encoding).to eq(Encoding.default_external) - end + it 'returns last few lines' do + result = stream.raw(last_lines: 2) - context 'limit max lines' do - before do - # specifying BUFFER_SIZE forces to seek backwards - allow(described_class).to receive(:BUFFER_SIZE) - .and_return(2) + expect(result).to eq(lines.last(2).join) + expect(result.encoding).to eq(Encoding.default_external) + end + + it 'returns everything if trying to get too many lines' do + result = stream.raw(last_lines: lines.size * 2) + + expect(result).to eq(lines.join) + expect(result.encoding).to eq(Encoding.default_external) + end end + end - it 'returns last few lines' do - result = stream.raw(last_lines: 2) + let(:path) { __FILE__ } + let(:lines) { File.readlines(path) } - expect(result).to eq(lines.last(2).join) - expect(result.encoding).to eq(Encoding.default_external) + context 'when stream is File' do + let(:stream) do + described_class.new do + File.open(path) + end end - it 'returns everything if trying to get too many lines' do - result = stream.raw(last_lines: lines.size * 2) + it_behaves_like 'sets' + end - expect(result).to eq(lines.join) - expect(result.encoding).to eq(Encoding.default_external) + context 'when stream is ChunkedIO' do + let(:stream) do + described_class.new do + Gitlab::Ci::Trace::ChunkedIO.new(build).tap do |chunked_io| + chunked_io.write(File.binread(path)) + chunked_io.seek(0, IO::SEEK_SET) + end + end end + + it_behaves_like 'sets' end end describe '#html_with_state' do - let(:stream) do - described_class.new do - StringIO.new("1234") + shared_examples_for 'html_with_states' do + it 'returns html content with state' do + result = stream.html_with_state + + expect(result.html).to eq("1234") end - end - it 'returns html content with state' do - result = stream.html_with_state + context 'follow-up state' do + let!(:last_result) { stream.html_with_state } - expect(result.html).to eq("1234") - end + before do + stream.append("5678", 4) + stream.seek(0) + end - context 'follow-up state' do - let!(:last_result) { stream.html_with_state } + it "returns appended trace" do + result = stream.html_with_state(last_result.state) - before do - stream.append("5678", 4) - stream.seek(0) + expect(result.append).to be_truthy + expect(result.html).to eq("5678") + end + end + end + + context 'when stream is StringIO' do + let(:stream) do + described_class.new do + StringIO.new("1234") + end end - it "returns appended trace" do - result = stream.html_with_state(last_result.state) + it_behaves_like 'html_with_states' + end - expect(result.append).to be_truthy - expect(result.html).to eq("5678") + context 'when stream is ChunkedIO' do + let(:stream) do + described_class.new do + Gitlab::Ci::Trace::ChunkedIO.new(build).tap do |chunked_io| + chunked_io.write("1234") + chunked_io.seek(0, IO::SEEK_SET) + end + end end + + it_behaves_like 'html_with_states' end end describe '#html' do - let(:stream) do - described_class.new do - StringIO.new("12\n34\n56") + shared_examples_for 'htmls' do + it "returns html" do + expect(stream.html).to eq("12<br>34<br>56") + end + + it "returns html for last line only" do + expect(stream.html(last_lines: 1)).to eq("56") end end - it "returns html" do - expect(stream.html).to eq("12<br>34<br>56") + context 'when stream is StringIO' do + let(:stream) do + described_class.new do + StringIO.new("12\n34\n56") + end + end + + it_behaves_like 'htmls' end - it "returns html for last line only" do - expect(stream.html(last_lines: 1)).to eq("56") + context 'when stream is ChunkedIO' do + let(:stream) do + described_class.new do + Gitlab::Ci::Trace::ChunkedIO.new(build).tap do |chunked_io| + chunked_io.write("12\n34\n56") + chunked_io.seek(0, IO::SEEK_SET) + end + end + end + + it_behaves_like 'htmls' end end describe '#extract_coverage' do - let(:stream) do - described_class.new do - StringIO.new(data) - end - end + shared_examples_for 'extract_coverages' do + context 'valid content & regex' do + let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered' } + let(:regex) { '\(\d+.\d+\%\) covered' } - subject { stream.extract_coverage(regex) } + it { is_expected.to eq("98.29") } + end - context 'valid content & regex' do - let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered' } - let(:regex) { '\(\d+.\d+\%\) covered' } + context 'valid content & bad regex' do + let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered\n' } + let(:regex) { 'very covered' } - it { is_expected.to eq("98.29") } - end + it { is_expected.to be_nil } + end - context 'valid content & bad regex' do - let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered\n' } - let(:regex) { 'very covered' } + context 'no coverage content & regex' do + let(:data) { 'No coverage for today :sad:' } + let(:regex) { '\(\d+.\d+\%\) covered' } - it { is_expected.to be_nil } - end + it { is_expected.to be_nil } + end - context 'no coverage content & regex' do - let(:data) { 'No coverage for today :sad:' } - let(:regex) { '\(\d+.\d+\%\) covered' } + context 'multiple results in content & regex' do + let(:data) do + <<~HEREDOC + (98.39%) covered + (98.29%) covered + HEREDOC + end - it { is_expected.to be_nil } - end + let(:regex) { '\(\d+.\d+\%\) covered' } - context 'multiple results in content & regex' do - let(:data) do - <<~HEREDOC - (98.39%) covered - (98.29%) covered - HEREDOC + it 'returns the last matched coverage' do + is_expected.to eq("98.29") + end end - let(:regex) { '\(\d+.\d+\%\) covered' } + context 'when BUFFER_SIZE is smaller than stream.size' do + let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered\n' } + let(:regex) { '\(\d+.\d+\%\) covered' } - it 'returns the last matched coverage' do - is_expected.to eq("98.29") + before do + stub_const('Gitlab::Ci::Trace::Stream::BUFFER_SIZE', 5) + end + + it { is_expected.to eq("98.29") } end - end - context 'when BUFFER_SIZE is smaller than stream.size' do - let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered\n' } - let(:regex) { '\(\d+.\d+\%\) covered' } + context 'when regex is multi-byte char' do + let(:data) { '95.0 ゴッドファット\n' } + let(:regex) { '\d+\.\d+ ゴッドファット' } - before do - stub_const('Gitlab::Ci::Trace::Stream::BUFFER_SIZE', 5) + before do + stub_const('Gitlab::Ci::Trace::Stream::BUFFER_SIZE', 5) + end + + it { is_expected.to eq('95.0') } end - it { is_expected.to eq("98.29") } - end + context 'when BUFFER_SIZE is equal to stream.size' do + let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered\n' } + let(:regex) { '\(\d+.\d+\%\) covered' } - context 'when regex is multi-byte char' do - let(:data) { '95.0 ゴッドファット\n' } - let(:regex) { '\d+\.\d+ ゴッドファット' } + before do + stub_const('Gitlab::Ci::Trace::Stream::BUFFER_SIZE', data.length) + end - before do - stub_const('Gitlab::Ci::Trace::Stream::BUFFER_SIZE', 5) + it { is_expected.to eq("98.29") } end - it { is_expected.to eq('95.0') } - end - - context 'when BUFFER_SIZE is equal to stream.size' do - let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered\n' } - let(:regex) { '\(\d+.\d+\%\) covered' } + context 'using a regex capture' do + let(:data) { 'TOTAL 9926 3489 65%' } + let(:regex) { 'TOTAL\s+\d+\s+\d+\s+(\d{1,3}\%)' } - before do - stub_const('Gitlab::Ci::Trace::Stream::BUFFER_SIZE', data.length) + it { is_expected.to eq("65") } end - it { is_expected.to eq("98.29") } - end + context 'malicious regexp' do + let(:data) { malicious_text } + let(:regex) { malicious_regexp } - context 'using a regex capture' do - let(:data) { 'TOTAL 9926 3489 65%' } - let(:regex) { 'TOTAL\s+\d+\s+\d+\s+(\d{1,3}\%)' } + include_examples 'malicious regexp' + end - it { is_expected.to eq("65") } - end + context 'multi-line data with rooted regexp' do + let(:data) { "\n65%\n" } + let(:regex) { '^(\d+)\%$' } - context 'malicious regexp' do - let(:data) { malicious_text } - let(:regex) { malicious_regexp } + it { is_expected.to eq('65') } + end - include_examples 'malicious regexp' - end + context 'long line' do + let(:data) { 'a' * 80000 + '100%' + 'a' * 80000 } + let(:regex) { '\d+\%' } - context 'multi-line data with rooted regexp' do - let(:data) { "\n65%\n" } - let(:regex) { '^(\d+)\%$' } + it { is_expected.to eq('100') } + end - it { is_expected.to eq('65') } - end + context 'many lines' do + let(:data) { "foo\n" * 80000 + "100%\n" + "foo\n" * 80000 } + let(:regex) { '\d+\%' } - context 'long line' do - let(:data) { 'a' * 80000 + '100%' + 'a' * 80000 } - let(:regex) { '\d+\%' } + it { is_expected.to eq('100') } + end - it { is_expected.to eq('100') } - end + context 'empty regex' do + let(:data) { 'foo' } + let(:regex) { '' } - context 'many lines' do - let(:data) { "foo\n" * 80000 + "100%\n" + "foo\n" * 80000 } - let(:regex) { '\d+\%' } + it 'skips processing' do + expect(stream).not_to receive(:read) - it { is_expected.to eq('100') } - end + is_expected.to be_nil + end + end - context 'empty regex' do - let(:data) { 'foo' } - let(:regex) { '' } + context 'nil regex' do + let(:data) { 'foo' } + let(:regex) { nil } - it 'skips processing' do - expect(stream).not_to receive(:read) + it 'skips processing' do + expect(stream).not_to receive(:read) - is_expected.to be_nil + is_expected.to be_nil + end end end - context 'nil regex' do - let(:data) { 'foo' } - let(:regex) { nil } + subject { stream.extract_coverage(regex) } - it 'skips processing' do - expect(stream).not_to receive(:read) + context 'when stream is StringIO' do + let(:stream) do + described_class.new do + StringIO.new(data) + end + end + + it_behaves_like 'extract_coverages' + end - is_expected.to be_nil + context 'when stream is ChunkedIO' do + let(:stream) do + described_class.new do + Gitlab::Ci::Trace::ChunkedIO.new(build).tap do |chunked_io| + chunked_io.write(data) + chunked_io.seek(0, IO::SEEK_SET) + end + end end + + it_behaves_like 'extract_coverages' end end end diff --git a/spec/lib/gitlab/ci/trace_spec.rb b/spec/lib/gitlab/ci/trace_spec.rb index 6a9c6442282..e9d755c2021 100644 --- a/spec/lib/gitlab/ci/trace_spec.rb +++ b/spec/lib/gitlab/ci/trace_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::Ci::Trace do +describe Gitlab::Ci::Trace, :clean_gitlab_redis_cache do let(:build) { create(:ci_build) } let(:trace) { described_class.new(build) } @@ -9,552 +9,19 @@ describe Gitlab::Ci::Trace do it { expect(trace).to delegate_method(:old_trace).to(:job) } end - describe '#html' do + context 'when live trace feature is disabled' do before do - trace.set("12\n34") + stub_feature_flags(ci_enable_live_trace: false) end - it "returns formatted html" do - expect(trace.html).to eq("12<br>34") - end - - it "returns last line of formatted html" do - expect(trace.html(last_lines: 1)).to eq("34") - end - end - - describe '#raw' do - before do - trace.set("12\n34") - end - - it "returns raw output" do - expect(trace.raw).to eq("12\n34") - end - - it "returns last line of raw output" do - expect(trace.raw(last_lines: 1)).to eq("34") - end - end - - describe '#extract_coverage' do - let(:regex) { '\(\d+.\d+\%\) covered' } - - context 'matching coverage' do - before do - trace.set('Coverage 1033 / 1051 LOC (98.29%) covered') - end - - it "returns valid coverage" do - expect(trace.extract_coverage(regex)).to eq("98.29") - end - end - - context 'no coverage' do - before do - trace.set('No coverage') - end - - it 'returs nil' do - expect(trace.extract_coverage(regex)).to be_nil - end - end - end - - describe '#extract_sections' do - let(:log) { 'No sections' } - let(:sections) { trace.extract_sections } - - before do - trace.set(log) - end - - context 'no sections' do - it 'returs []' do - expect(trace.extract_sections).to eq([]) - end - end - - context 'multiple sections available' do - let(:log) { File.read(expand_fixture_path('trace/trace_with_sections')) } - let(:sections_data) do - [ - { name: 'prepare_script', lines: 2, duration: 3.seconds }, - { name: 'get_sources', lines: 4, duration: 1.second }, - { name: 'restore_cache', lines: 0, duration: 0.seconds }, - { name: 'download_artifacts', lines: 0, duration: 0.seconds }, - { name: 'build_script', lines: 2, duration: 1.second }, - { name: 'after_script', lines: 0, duration: 0.seconds }, - { name: 'archive_cache', lines: 0, duration: 0.seconds }, - { name: 'upload_artifacts', lines: 0, duration: 0.seconds } - ] - end - - it "returns valid sections" do - expect(sections).not_to be_empty - expect(sections.size).to eq(sections_data.size), - "expected #{sections_data.size} sections, got #{sections.size}" - - buff = StringIO.new(log) - sections.each_with_index do |s, i| - expected = sections_data[i] - - expect(s[:name]).to eq(expected[:name]) - expect(s[:date_end] - s[:date_start]).to eq(expected[:duration]) - - buff.seek(s[:byte_start], IO::SEEK_SET) - length = s[:byte_end] - s[:byte_start] - lines = buff.read(length).count("\n") - expect(lines).to eq(expected[:lines]) - end - end - end - - context 'logs contains "section_start"' do - let(:log) { "section_start:1506417476:a_section\r\033[0Klooks like a section_start:invalid\nsection_end:1506417477:a_section\r\033[0K"} - - it "returns only one section" do - expect(sections).not_to be_empty - expect(sections.size).to eq(1) - - section = sections[0] - expect(section[:name]).to eq('a_section') - expect(section[:byte_start]).not_to eq(section[:byte_end]), "got an empty section" - end - end - - context 'missing section_end' do - let(:log) { "section_start:1506417476:a_section\r\033[0KSome logs\nNo section_end\n"} - - it "returns no sections" do - expect(sections).to be_empty - end - end - - context 'missing section_start' do - let(:log) { "Some logs\nNo section_start\nsection_end:1506417476:a_section\r\033[0K"} - - it "returns no sections" do - expect(sections).to be_empty - end - end - - context 'inverted section_start section_end' do - let(:log) { "section_end:1506417476:a_section\r\033[0Klooks like a section_start:invalid\nsection_start:1506417477:a_section\r\033[0K"} - - it "returns no sections" do - expect(sections).to be_empty - end - end - end - - describe '#set' do - before do - trace.set("12") - end - - it "returns trace" do - expect(trace.raw).to eq("12") - end - - context 'overwrite trace' do - before do - trace.set("34") - end - - it "returns new trace" do - expect(trace.raw).to eq("34") - end - end - - context 'runners token' do - let(:token) { 'my_secret_token' } - - before do - build.project.update(runners_token: token) - trace.set(token) - end - - it "hides token" do - expect(trace.raw).not_to include(token) - end - end - - context 'hides build token' do - let(:token) { 'my_secret_token' } - - before do - build.update(token: token) - trace.set(token) - end - - it "hides token" do - expect(trace.raw).not_to include(token) - end - end + it_behaves_like 'trace with disabled live trace feature' end - describe '#append' do + context 'when live trace feature is enabled' do before do - trace.set("1234") - end - - it "returns correct trace" do - expect(trace.append("56", 4)).to eq(6) - expect(trace.raw).to eq("123456") - end - - context 'tries to append trace at different offset' do - it "fails with append" do - expect(trace.append("56", 2)).to eq(-4) - expect(trace.raw).to eq("1234") - end - end - - context 'runners token' do - let(:token) { 'my_secret_token' } - - before do - build.project.update(runners_token: token) - trace.append(token, 0) - end - - it "hides token" do - expect(trace.raw).not_to include(token) - end - end - - context 'build token' do - let(:token) { 'my_secret_token' } - - before do - build.update(token: token) - trace.append(token, 0) - end - - it "hides token" do - expect(trace.raw).not_to include(token) - end - end - end - - describe '#read' do - shared_examples 'read successfully with IO' do - it 'yields with source' do - trace.read do |stream| - expect(stream).to be_a(Gitlab::Ci::Trace::Stream) - expect(stream.stream).to be_a(IO) - end - end - end - - shared_examples 'read successfully with StringIO' do - it 'yields with source' do - trace.read do |stream| - expect(stream).to be_a(Gitlab::Ci::Trace::Stream) - expect(stream.stream).to be_a(StringIO) - end - end - end - - shared_examples 'failed to read' do - it 'yields without source' do - trace.read do |stream| - expect(stream).to be_a(Gitlab::Ci::Trace::Stream) - expect(stream.stream).to be_nil - end - end - end - - context 'when trace artifact exists' do - before do - create(:ci_job_artifact, :trace, job: build) - end - - it_behaves_like 'read successfully with IO' - end - - context 'when current_path (with project_id) exists' do - before do - expect(trace).to receive(:default_path) { expand_fixture_path('trace/sample_trace') } - end - - it_behaves_like 'read successfully with IO' - end - - context 'when current_path (with project_ci_id) exists' do - before do - expect(trace).to receive(:deprecated_path) { expand_fixture_path('trace/sample_trace') } - end - - it_behaves_like 'read successfully with IO' - end - - context 'when db trace exists' do - before do - build.send(:write_attribute, :trace, "data") - end - - it_behaves_like 'read successfully with StringIO' - end - - context 'when no sources exist' do - it_behaves_like 'failed to read' - end - end - - describe 'trace handling' do - subject { trace.exist? } - - context 'trace does not exist' do - it { expect(trace.exist?).to be(false) } - end - - context 'when trace artifact exists' do - before do - create(:ci_job_artifact, :trace, job: build) - end - - it { is_expected.to be_truthy } - - context 'when the trace artifact has been erased' do - before do - trace.erase! - end - - it { is_expected.to be_falsy } - - it 'removes associations' do - expect(Ci::JobArtifact.exists?(job_id: build.id, file_type: :trace)).to be_falsy - end - end - end - - context 'new trace path is used' do - before do - trace.send(:ensure_directory) - - File.open(trace.send(:default_path), "w") do |file| - file.write("data") - end - end - - it "trace exist" do - expect(trace.exist?).to be(true) - end - - it "can be erased" do - trace.erase! - expect(trace.exist?).to be(false) - end - end - - context 'deprecated path' do - let(:path) { trace.send(:deprecated_path) } - - context 'with valid ci_id' do - before do - build.project.update(ci_id: 1000) - - FileUtils.mkdir_p(File.dirname(path)) - - File.open(path, "w") do |file| - file.write("data") - end - end - - it "trace exist" do - expect(trace.exist?).to be(true) - end - - it "can be erased" do - trace.erase! - expect(trace.exist?).to be(false) - end - end - - context 'without valid ci_id' do - it "does not return deprecated path" do - expect(path).to be_nil - end - end - end - - context 'stored in database' do - before do - build.send(:write_attribute, :trace, "data") - end - - it "trace exist" do - expect(trace.exist?).to be(true) - end - - it "can be erased" do - trace.erase! - expect(trace.exist?).to be(false) - end - - it "returns database data" do - expect(trace.raw).to eq("data") - end - end - end - - describe '#archive!' do - subject { trace.archive! } - - shared_examples 'archive trace file' do - it do - expect { subject }.to change { Ci::JobArtifact.count }.by(1) - - build.reload - expect(build.trace.exist?).to be_truthy - expect(build.job_artifacts_trace.file.exists?).to be_truthy - expect(build.job_artifacts_trace.file.filename).to eq('job.log') - expect(File.exist?(src_path)).to be_falsy - expect(src_checksum) - .to eq(Digest::SHA256.file(build.job_artifacts_trace.file.path).hexdigest) - expect(build.job_artifacts_trace.file_sha256).to eq(src_checksum) - end - end - - shared_examples 'source trace file stays intact' do |error:| - it do - expect { subject }.to raise_error(error) - - build.reload - expect(build.trace.exist?).to be_truthy - expect(build.job_artifacts_trace).to be_nil - expect(File.exist?(src_path)).to be_truthy - end - end - - shared_examples 'archive trace in database' do - it do - expect { subject }.to change { Ci::JobArtifact.count }.by(1) - - build.reload - expect(build.trace.exist?).to be_truthy - expect(build.job_artifacts_trace.file.exists?).to be_truthy - expect(build.job_artifacts_trace.file.filename).to eq('job.log') - expect(build.old_trace).to be_nil - expect(src_checksum) - .to eq(Digest::SHA256.file(build.job_artifacts_trace.file.path).hexdigest) - expect(build.job_artifacts_trace.file_sha256).to eq(src_checksum) - end - end - - shared_examples 'source trace in database stays intact' do |error:| - it do - expect { subject }.to raise_error(error) - - build.reload - expect(build.trace.exist?).to be_truthy - expect(build.job_artifacts_trace).to be_nil - expect(build.old_trace).to eq(trace_content) - end - end - - context 'when job does not have trace artifact' do - context 'when trace file stored in default path' do - let!(:build) { create(:ci_build, :success, :trace_live) } - let!(:src_path) { trace.read { |s| s.path } } - let!(:src_checksum) { Digest::SHA256.file(src_path).hexdigest } - - it_behaves_like 'archive trace file' - - context 'when failed to create clone file' do - before do - allow(IO).to receive(:copy_stream).and_return(0) - end - - it_behaves_like 'source trace file stays intact', error: Gitlab::Ci::Trace::ArchiveError - end - - context 'when failed to create job artifact record' do - before do - allow_any_instance_of(Ci::JobArtifact).to receive(:save).and_return(false) - allow_any_instance_of(Ci::JobArtifact).to receive_message_chain(:errors, :full_messages) - .and_return(%w[Error Error]) - end - - it_behaves_like 'source trace file stays intact', error: ActiveRecord::RecordInvalid - end - end - - context 'when trace is stored in database' do - let(:build) { create(:ci_build, :success) } - let(:trace_content) { 'Sample trace' } - let!(:src_checksum) { Digest::SHA256.hexdigest(trace_content) } - - before do - build.update_column(:trace, trace_content) - end - - it_behaves_like 'archive trace in database' - - context 'when failed to create clone file' do - before do - allow(IO).to receive(:copy_stream).and_return(0) - end - - it_behaves_like 'source trace in database stays intact', error: Gitlab::Ci::Trace::ArchiveError - end - - context 'when failed to create job artifact record' do - before do - allow_any_instance_of(Ci::JobArtifact).to receive(:save).and_return(false) - allow_any_instance_of(Ci::JobArtifact).to receive_message_chain(:errors, :full_messages) - .and_return(%w[Error Error]) - end - - it_behaves_like 'source trace in database stays intact', error: ActiveRecord::RecordInvalid - end - - context 'when there is a validation error on Ci::Build' do - before do - allow_any_instance_of(Ci::Build).to receive(:save).and_return(false) - allow_any_instance_of(Ci::Build).to receive_message_chain(:errors, :full_messages) - .and_return(%w[Error Error]) - end - - context "when erase old trace with 'save'" do - before do - build.send(:write_attribute, :trace, nil) - build.save - end - - it 'old trace is not deleted' do - build.reload - expect(build.trace.raw).to eq(trace_content) - end - end - - it_behaves_like 'archive trace in database' - end - end + stub_feature_flags(ci_enable_live_trace: true) end - context 'when job has trace artifact' do - before do - create(:ci_job_artifact, :trace, job: build) - end - - it 'does not archive' do - expect_any_instance_of(described_class).not_to receive(:archive_stream!) - expect { subject }.to raise_error('Already archived') - expect(build.job_artifacts_trace.file.exists?).to be_truthy - end - end - - context 'when job is not finished yet' do - let!(:build) { create(:ci_build, :running, :trace_live) } - - it 'does not archive' do - expect_any_instance_of(described_class).not_to receive(:archive_stream!) - expect { subject }.to raise_error('Job is not finished yet') - expect(build.trace.exist?).to be_truthy - end - end + it_behaves_like 'trace with enabled live trace feature' end end diff --git a/spec/lib/gitlab/data_builder/wiki_page_spec.rb b/spec/lib/gitlab/data_builder/wiki_page_spec.rb index a776d888c47..9c8bdf4b032 100644 --- a/spec/lib/gitlab/data_builder/wiki_page_spec.rb +++ b/spec/lib/gitlab/data_builder/wiki_page_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Gitlab::DataBuilder::WikiPage do - let(:project) { create(:project, :repository) } + set(:project) { create(:project, :repository, :wiki_repo) } let(:wiki_page) { create(:wiki_page, wiki: project.wiki) } let(:user) { create(:user) } diff --git a/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb b/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb index 6568a0b1bb0..452249210b0 100644 --- a/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb @@ -1,5 +1,4 @@ require 'spec_helper' -require_relative '../email_shared_blocks' describe Gitlab::Email::Handler::CreateIssueHandler do include_context :email_shared_context diff --git a/spec/lib/gitlab/email/handler/create_merge_request_handler_spec.rb b/spec/lib/gitlab/email/handler/create_merge_request_handler_spec.rb index dc1a93367a4..43c6280f251 100644 --- a/spec/lib/gitlab/email/handler/create_merge_request_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/create_merge_request_handler_spec.rb @@ -1,5 +1,4 @@ require 'spec_helper' -require_relative '../email_shared_blocks' describe Gitlab::Email::Handler::CreateMergeRequestHandler do include_context :email_shared_context diff --git a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb index 53899e00b53..950a7dd7d6c 100644 --- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb @@ -1,5 +1,4 @@ require 'spec_helper' -require_relative '../email_shared_blocks' describe Gitlab::Email::Handler::CreateNoteHandler do include_context :email_shared_context diff --git a/spec/lib/gitlab/email/handler/unsubscribe_handler_spec.rb b/spec/lib/gitlab/email/handler/unsubscribe_handler_spec.rb index 21796694f26..ce160e11de2 100644 --- a/spec/lib/gitlab/email/handler/unsubscribe_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/unsubscribe_handler_spec.rb @@ -1,5 +1,4 @@ require 'spec_helper' -require_relative '../email_shared_blocks' describe Gitlab::Email::Handler::UnsubscribeHandler do include_context :email_shared_context diff --git a/spec/lib/gitlab/email/receiver_spec.rb b/spec/lib/gitlab/email/receiver_spec.rb index 59f43abf26d..0af978eced3 100644 --- a/spec/lib/gitlab/email/receiver_spec.rb +++ b/spec/lib/gitlab/email/receiver_spec.rb @@ -1,5 +1,4 @@ require 'spec_helper' -require_relative 'email_shared_blocks' describe Gitlab::Email::Receiver do include_context :email_shared_context diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 9924641f829..9f091975959 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -1068,41 +1068,51 @@ describe Gitlab::Git::Repository, seed_helper: true do end describe '#raw_changes_between' do - let(:old_rev) { } - let(:new_rev) { } - let(:changes) { repository.raw_changes_between(old_rev, new_rev) } + shared_examples 'raw changes' do + let(:old_rev) { } + let(:new_rev) { } + let(:changes) { repository.raw_changes_between(old_rev, new_rev) } - context 'initial commit' do - let(:old_rev) { Gitlab::Git::BLANK_SHA } - let(:new_rev) { '1a0b36b3cdad1d2ee32457c102a8c0b7056fa863' } + context 'initial commit' do + let(:old_rev) { Gitlab::Git::BLANK_SHA } + let(:new_rev) { '1a0b36b3cdad1d2ee32457c102a8c0b7056fa863' } - it 'returns the changes' do - expect(changes).to be_present - expect(changes.size).to eq(3) + it 'returns the changes' do + expect(changes).to be_present + expect(changes.size).to eq(3) + end end - end - context 'with an invalid rev' do - let(:old_rev) { 'foo' } - let(:new_rev) { 'bar' } + context 'with an invalid rev' do + let(:old_rev) { 'foo' } + let(:new_rev) { 'bar' } - it 'returns an error' do - expect { changes }.to raise_error(Gitlab::Git::Repository::GitError) + it 'returns an error' do + expect { changes }.to raise_error(Gitlab::Git::Repository::GitError) + end end - end - context 'with valid revs' do - let(:old_rev) { 'fa1b1e6c004a68b7d8763b86455da9e6b23e36d6' } - let(:new_rev) { '4b4918a572fa86f9771e5ba40fbd48e1eb03e2c6' } + context 'with valid revs' do + let(:old_rev) { 'fa1b1e6c004a68b7d8763b86455da9e6b23e36d6' } + let(:new_rev) { '4b4918a572fa86f9771e5ba40fbd48e1eb03e2c6' } - it 'returns the changes' do - expect(changes.size).to eq(9) - expect(changes.first.operation).to eq(:modified) - expect(changes.first.new_path).to eq('.gitmodules') - expect(changes.last.operation).to eq(:added) - expect(changes.last.new_path).to eq('files/lfs/picture-invalid.png') + it 'returns the changes' do + expect(changes.size).to eq(9) + expect(changes.first.operation).to eq(:modified) + expect(changes.first.new_path).to eq('.gitmodules') + expect(changes.last.operation).to eq(:added) + expect(changes.last.new_path).to eq('files/lfs/picture-invalid.png') + end end end + + context 'when gitaly is enabled' do + it_behaves_like 'raw changes' + end + + context 'when gitaly is disabled', :disable_gitaly do + it_behaves_like 'raw changes' + end end describe '#merge_base' do diff --git a/spec/lib/gitlab/gitaly_client/repository_service_spec.rb b/spec/lib/gitlab/gitaly_client/repository_service_spec.rb index ecd8657c406..1547d447197 100644 --- a/spec/lib/gitlab/gitaly_client/repository_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/repository_service_spec.rb @@ -167,4 +167,15 @@ describe Gitlab::GitalyClient::RepositoryService do client.create_from_snapshot('http://example.com?wiki=1', 'Custom xyz') end end + + describe '#raw_changes_between' do + it 'sends a create_repository_from_snapshot message' do + expect_any_instance_of(Gitaly::RepositoryService::Stub) + .to receive(:get_raw_changes) + .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash)) + .and_return(double) + + client.raw_changes_between('deadbeef', 'deadpork') + end + end end diff --git a/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb b/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb index 879b1d9fb0f..cc9e4b67e72 100644 --- a/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' describe Gitlab::GithubImport::Importer::RepositoryImporter do let(:repository) { double(:repository) } + let(:import_state) { double(:import_state) } let(:client) { double(:client) } let(:project) do @@ -12,7 +13,8 @@ describe Gitlab::GithubImport::Importer::RepositoryImporter do repository_storage: 'foo', disk_path: 'foo', repository: repository, - create_wiki: true + create_wiki: true, + import_state: import_state ) end diff --git a/spec/lib/gitlab/github_import/parallel_importer_spec.rb b/spec/lib/gitlab/github_import/parallel_importer_spec.rb index e2a821d4d5c..20b48c1de68 100644 --- a/spec/lib/gitlab/github_import/parallel_importer_spec.rb +++ b/spec/lib/gitlab/github_import/parallel_importer_spec.rb @@ -12,6 +12,8 @@ describe Gitlab::GithubImport::ParallelImporter do let(:importer) { described_class.new(project) } before do + create(:import_state, :started, project: project) + expect(Gitlab::GithubImport::Stage::ImportRepositoryWorker) .to receive(:perform_async) .with(project.id) @@ -34,7 +36,7 @@ describe Gitlab::GithubImport::ParallelImporter do it 'updates the import JID of the project' do importer.execute - expect(project.import_jid).to eq("github-importer/#{project.id}") + expect(project.reload.import_jid).to eq("github-importer/#{project.id}") end end end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index e7f20f81fe0..ad76adcc2e5 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -258,7 +258,6 @@ project: - builds - runner_projects - runners -- active_runners - variables - triggers - pipeline_schedules @@ -269,13 +268,16 @@ project: - pages_domains - authorized_users - project_authorizations +- remote_mirrors - route - redirect_routes - statistics - container_repositories - uploads +- import_state - members_and_requesters - build_trace_section_names +- build_trace_chunks - root_of_fork_network - fork_network_member - fork_network @@ -286,6 +288,7 @@ project: - internal_ids - project_deploy_tokens - deploy_tokens +- settings - ci_cd_settings award_emoji: - awardable diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 31141807cb2..62da967cf96 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -232,6 +232,7 @@ Ci::Stage: - id - name - status +- position - lock_version - project_id - pipeline_id diff --git a/spec/lib/gitlab/import_export/wiki_repo_saver_spec.rb b/spec/lib/gitlab/import_export/wiki_repo_saver_spec.rb index d2bd8ccdf3f..24bc231d5a0 100644 --- a/spec/lib/gitlab/import_export/wiki_repo_saver_spec.rb +++ b/spec/lib/gitlab/import_export/wiki_repo_saver_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Gitlab::ImportExport::WikiRepoSaver do describe 'bundle a wiki Git repo' do let(:user) { create(:user) } - let!(:project) { create(:project, :public, name: 'searchable_project') } + let!(:project) { create(:project, :public, :wiki_repo, name: 'searchable_project') } let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" } let(:shared) { project.import_export_shared } let(:wiki_bundler) { described_class.new(project: project, shared: shared) } diff --git a/spec/lib/gitlab/incoming_email_spec.rb b/spec/lib/gitlab/incoming_email_spec.rb index c959add7a36..ad087f42e06 100644 --- a/spec/lib/gitlab/incoming_email_spec.rb +++ b/spec/lib/gitlab/incoming_email_spec.rb @@ -24,7 +24,7 @@ describe Gitlab::IncomingEmail do end describe 'self.supports_wildcard?' do - context 'address contains the wildard placeholder' do + context 'address contains the wildcard placeholder' do before do stub_incoming_email_setting(address: 'replies+%{key}@example.com') end @@ -49,7 +49,7 @@ describe Gitlab::IncomingEmail do stub_incoming_email_setting(address: nil) end - it 'returns that wildard is not supported' do + it 'returns that wildcard is not supported' do expect(described_class.supports_wildcard?).to be_falsey end end diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index 8351b967133..a34b7d9905a 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -175,14 +175,14 @@ describe Gitlab::ProjectSearchResults do end describe 'wiki search' do - let(:project) { create(:project, :public) } + let(:project) { create(:project, :public, :wiki_repo) } let(:wiki) { build(:project_wiki, project: project) } let!(:wiki_page) { wiki.create_page('Title', 'Content') } subject(:results) { described_class.new(user, project, 'Content').objects('wiki_blobs') } context 'when wiki is disabled' do - let(:project) { create(:project, :public, :wiki_disabled) } + let(:project) { create(:project, :public, :wiki_repo, :wiki_disabled) } it 'hides wiki blobs from members' do project.add_reporter(user) @@ -196,7 +196,7 @@ describe Gitlab::ProjectSearchResults do end context 'when wiki is internal' do - let(:project) { create(:project, :public, :wiki_private) } + let(:project) { create(:project, :public, :wiki_repo, :wiki_private) } it 'finds wiki blobs for guest' do project.add_guest(user) diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index 9e6aa109a4b..a716e6f5434 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -96,6 +96,7 @@ describe Gitlab::UsageData do pages_domains protected_branches releases + remote_mirrors snippets todos uploads diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 43e419cd7de..84ddbbbf2ee 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -654,38 +654,6 @@ describe Notify do allow(Note).to receive(:find).with(note.id).and_return(note) end - shared_examples 'a note email' do - it_behaves_like 'it should have Gmail Actions links' - - it 'is sent to the given recipient as the author' do - sender = subject.header[:from].addrs[0] - - aggregate_failures do - expect(sender.display_name).to eq(note_author.name) - expect(sender.address).to eq(gitlab_sender) - expect(subject).to deliver_to(recipient.notification_email) - end - end - - it 'contains the message from the note' do - is_expected.to have_html_escaped_body_text note.note - end - - it 'does not contain note author' do - is_expected.not_to have_body_text note.author_name - end - - context 'when enabled email_author_in_body' do - before do - stub_application_setting(email_author_in_body: true) - end - - it 'contains a link to note author' do - is_expected.to have_html_escaped_body_text note.author_name - end - end - end - describe 'on a commit' do let(:commit) { project.commit } diff --git a/spec/migrations/cleanup_build_stage_migration_spec.rb b/spec/migrations/cleanup_build_stage_migration_spec.rb new file mode 100644 index 00000000000..4d4d02aaa94 --- /dev/null +++ b/spec/migrations/cleanup_build_stage_migration_spec.rb @@ -0,0 +1,53 @@ +require 'spec_helper' +require Rails.root.join('db', 'migrate', '20180420010616_cleanup_build_stage_migration.rb') + +describe CleanupBuildStageMigration, :migration, :sidekiq, :redis do + let(:migration) { spy('migration') } + + before do + allow(Gitlab::BackgroundMigration::MigrateBuildStage) + .to receive(:new).and_return(migration) + end + + context 'when there are pending background migrations' do + it 'processes pending jobs synchronously' do + Sidekiq::Testing.disable! do + BackgroundMigrationWorker + .perform_in(2.minutes, 'MigrateBuildStage', [1, 1]) + BackgroundMigrationWorker + .perform_async('MigrateBuildStage', [1, 1]) + + migrate! + + expect(migration).to have_received(:perform).with(1, 1).twice + end + end + end + + context 'when there are no background migrations pending' do + it 'does nothing' do + Sidekiq::Testing.disable! do + migrate! + + expect(migration).not_to have_received(:perform) + end + end + end + + context 'when there are still unmigrated builds present' do + let(:builds) { table('ci_builds') } + + before do + builds.create!(name: 'test:1', ref: 'master') + builds.create!(name: 'test:2', ref: 'master') + end + + it 'migrates stages sequentially in batches' do + expect(builds.all).to all(have_attributes(stage_id: nil)) + + migrate! + + expect(migration).to have_received(:perform).once + end + end +end diff --git a/spec/migrations/migrate_import_attributes_data_from_projects_to_project_mirror_data_spec.rb b/spec/migrations/migrate_import_attributes_data_from_projects_to_project_mirror_data_spec.rb new file mode 100644 index 00000000000..972c6dffc6f --- /dev/null +++ b/spec/migrations/migrate_import_attributes_data_from_projects_to_project_mirror_data_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20180502134117_migrate_import_attributes_data_from_projects_to_project_mirror_data.rb') + +describe MigrateImportAttributesDataFromProjectsToProjectMirrorData, :sidekiq, :migration do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:import_state) { table(:project_mirror_data) } + + before do + stub_const("#{described_class}::BATCH_SIZE", 1) + namespaces.create(id: 1, name: 'gitlab-org', path: 'gitlab-org') + + projects.create!(id: 1, namespace_id: 1, name: 'gitlab1', + path: 'gitlab1', import_error: "foo", import_status: :started, + import_url: generate(:url)) + projects.create!(id: 2, namespace_id: 1, name: 'gitlab2', + path: 'gitlab2', import_error: "bar", import_status: :failed, + import_url: generate(:url)) + projects.create!(id: 3, namespace_id: 1, name: 'gitlab3', path: 'gitlab3', import_status: :none, import_url: generate(:url)) + end + + it 'schedules delayed background migrations in batches in bulk' do + Sidekiq::Testing.fake! do + Timecop.freeze do + expect(projects.where.not(import_status: :none).count).to eq(2) + + subject.up + + expect(BackgroundMigrationWorker.jobs.size).to eq 2 + expect(described_class::UP_MIGRATION).to be_scheduled_delayed_migration(5.minutes, 1, 1) + expect(described_class::UP_MIGRATION).to be_scheduled_delayed_migration(10.minutes, 2, 2) + end + end + end + + describe '#down' do + before do + import_state.create!(id: 1, project_id: 1, status: :started) + import_state.create!(id: 2, project_id: 2, status: :started) + end + + it 'schedules delayed background migrations in batches in bulk for rollback' do + Sidekiq::Testing.fake! do + Timecop.freeze do + expect(import_state.where.not(status: :none).count).to eq(2) + + subject.down + + expect(BackgroundMigrationWorker.jobs.size).to eq 2 + expect(described_class::DOWN_MIGRATION).to be_scheduled_delayed_migration(5.minutes, 1, 1) + expect(described_class::DOWN_MIGRATION).to be_scheduled_delayed_migration(10.minutes, 2, 2) + end + end + end + end +end diff --git a/spec/migrations/schedule_stages_index_migration_spec.rb b/spec/migrations/schedule_stages_index_migration_spec.rb new file mode 100644 index 00000000000..710264da375 --- /dev/null +++ b/spec/migrations/schedule_stages_index_migration_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20180420080616_schedule_stages_index_migration') + +describe ScheduleStagesIndexMigration, :sidekiq, :migration do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:pipelines) { table(:ci_pipelines) } + let(:stages) { table(:ci_stages) } + + before do + stub_const("#{described_class}::BATCH_SIZE", 1) + + namespaces.create(id: 12, name: 'gitlab-org', path: 'gitlab-org') + projects.create!(id: 123, namespace_id: 12, name: 'gitlab', path: 'gitlab') + pipelines.create!(id: 1, project_id: 123, ref: 'master', sha: 'adf43c3a') + stages.create!(id: 121, project_id: 123, pipeline_id: 1, name: 'build') + stages.create!(id: 122, project_id: 123, pipeline_id: 1, name: 'test') + stages.create!(id: 123, project_id: 123, pipeline_id: 1, name: 'deploy') + end + + it 'schedules delayed background migrations in batches' do + Sidekiq::Testing.fake! do + Timecop.freeze do + expect(stages.all).to all(have_attributes(position: be_nil)) + + migrate! + + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(5.minutes, 121, 121) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(10.minutes, 122, 122) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(15.minutes, 123, 123) + expect(BackgroundMigrationWorker.jobs.size).to eq 3 + end + end + end +end diff --git a/spec/models/active_session_spec.rb b/spec/models/active_session_spec.rb new file mode 100644 index 00000000000..129b2f92683 --- /dev/null +++ b/spec/models/active_session_spec.rb @@ -0,0 +1,216 @@ +require 'rails_helper' + +RSpec.describe ActiveSession, :clean_gitlab_redis_shared_state do + let(:user) do + create(:user).tap do |user| + user.current_sign_in_at = Time.current + end + end + + let(:session) { double(:session, id: '6919a6f1bb119dd7396fadc38fd18d0d') } + + let(:request) do + double(:request, { + user_agent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 8_1_3 like Mac OS X) AppleWebKit/600.1.4 ' \ + '(KHTML, like Gecko) Mobile/12B466 [FBDV/iPhone7,2]', + ip: '127.0.0.1', + session: session + }) + end + + describe '#current?' do + it 'returns true if the active session matches the current session' do + active_session = ActiveSession.new(session_id: '6919a6f1bb119dd7396fadc38fd18d0d') + + expect(active_session.current?(session)).to be true + end + + it 'returns false if the active session does not match the current session' do + active_session = ActiveSession.new(session_id: '59822c7d9fcdfa03725eff41782ad97d') + + expect(active_session.current?(session)).to be false + end + + it 'returns false if the session id is nil' do + active_session = ActiveSession.new(session_id: nil) + session = double(:session, id: nil) + + expect(active_session.current?(session)).to be false + end + end + + describe '.list' do + it 'returns all sessions by user' do + Gitlab::Redis::SharedState.with do |redis| + redis.set("session:user:gitlab:#{user.id}:6919a6f1bb119dd7396fadc38fd18d0d", Marshal.dump({ session_id: 'a' })) + redis.set("session:user:gitlab:#{user.id}:59822c7d9fcdfa03725eff41782ad97d", Marshal.dump({ session_id: 'b' })) + redis.set("session:user:gitlab:9999:5c8611e4f9c69645ad1a1492f4131358", '') + + redis.sadd( + "session:lookup:user:gitlab:#{user.id}", + %w[ + 6919a6f1bb119dd7396fadc38fd18d0d + 59822c7d9fcdfa03725eff41782ad97d + ] + ) + end + + expect(ActiveSession.list(user)).to match_array [{ session_id: 'a' }, { session_id: 'b' }] + end + + it 'does not return obsolete entries and cleans them up' do + Gitlab::Redis::SharedState.with do |redis| + redis.set("session:user:gitlab:#{user.id}:6919a6f1bb119dd7396fadc38fd18d0d", Marshal.dump({ session_id: 'a' })) + + redis.sadd( + "session:lookup:user:gitlab:#{user.id}", + %w[ + 6919a6f1bb119dd7396fadc38fd18d0d + 59822c7d9fcdfa03725eff41782ad97d + ] + ) + end + + expect(ActiveSession.list(user)).to eq [{ session_id: 'a' }] + + Gitlab::Redis::SharedState.with do |redis| + expect(redis.sscan_each("session:lookup:user:gitlab:#{user.id}").to_a).to eq ['6919a6f1bb119dd7396fadc38fd18d0d'] + end + end + + it 'returns an empty array if the use does not have any active session' do + expect(ActiveSession.list(user)).to eq [] + end + end + + describe '.set' do + it 'sets a new redis entry for the user session and a lookup entry' do + ActiveSession.set(user, request) + + Gitlab::Redis::SharedState.with do |redis| + expect(redis.scan_each.to_a).to match_array [ + "session:user:gitlab:#{user.id}:6919a6f1bb119dd7396fadc38fd18d0d", + "session:lookup:user:gitlab:#{user.id}" + ] + end + end + + it 'adds timestamps and information from the request' do + Timecop.freeze(Time.zone.parse('2018-03-12 09:06')) do + ActiveSession.set(user, request) + + session = ActiveSession.list(user) + + expect(session.count).to eq 1 + expect(session.first).to have_attributes( + ip_address: '127.0.0.1', + browser: 'Mobile Safari', + os: 'iOS', + device_name: 'iPhone 6', + device_type: 'smartphone', + created_at: Time.zone.parse('2018-03-12 09:06'), + updated_at: Time.zone.parse('2018-03-12 09:06'), + session_id: '6919a6f1bb119dd7396fadc38fd18d0d' + ) + end + end + + it 'keeps the created_at from the login on consecutive requests' do + now = Time.zone.parse('2018-03-12 09:06') + + Timecop.freeze(now) do + ActiveSession.set(user, request) + + Timecop.freeze(now + 1.minute) do + ActiveSession.set(user, request) + + session = ActiveSession.list(user) + + expect(session.first).to have_attributes( + created_at: Time.zone.parse('2018-03-12 09:06'), + updated_at: Time.zone.parse('2018-03-12 09:07') + ) + end + end + end + end + + describe '.destroy' do + it 'removes the entry associated with the currently killed user session' do + Gitlab::Redis::SharedState.with do |redis| + redis.set("session:user:gitlab:#{user.id}:6919a6f1bb119dd7396fadc38fd18d0d", '') + redis.set("session:user:gitlab:#{user.id}:59822c7d9fcdfa03725eff41782ad97d", '') + redis.set("session:user:gitlab:9999:5c8611e4f9c69645ad1a1492f4131358", '') + end + + ActiveSession.destroy(user, request.session.id) + + Gitlab::Redis::SharedState.with do |redis| + expect(redis.scan_each(match: "session:user:gitlab:*")).to match_array [ + "session:user:gitlab:#{user.id}:59822c7d9fcdfa03725eff41782ad97d", + "session:user:gitlab:9999:5c8611e4f9c69645ad1a1492f4131358" + ] + end + end + + it 'removes the lookup entry' do + Gitlab::Redis::SharedState.with do |redis| + redis.set("session:user:gitlab:#{user.id}:6919a6f1bb119dd7396fadc38fd18d0d", '') + redis.sadd("session:lookup:user:gitlab:#{user.id}", '6919a6f1bb119dd7396fadc38fd18d0d') + end + + ActiveSession.destroy(user, request.session.id) + + Gitlab::Redis::SharedState.with do |redis| + expect(redis.scan_each(match: "session:lookup:user:gitlab:#{user.id}").to_a).to be_empty + end + end + + it 'removes the devise session' do + Gitlab::Redis::SharedState.with do |redis| + redis.set("session:user:gitlab:#{user.id}:6919a6f1bb119dd7396fadc38fd18d0d", '') + redis.set("session:gitlab:6919a6f1bb119dd7396fadc38fd18d0d", '') + end + + ActiveSession.destroy(user, request.session.id) + + Gitlab::Redis::SharedState.with do |redis| + expect(redis.scan_each(match: "session:gitlab:*").to_a).to be_empty + end + end + + it 'does not remove the devise session if the active session could not be found' do + Gitlab::Redis::SharedState.with do |redis| + redis.set("session:gitlab:6919a6f1bb119dd7396fadc38fd18d0d", '') + end + + other_user = create(:user) + + ActiveSession.destroy(other_user, request.session.id) + + Gitlab::Redis::SharedState.with do |redis| + expect(redis.scan_each(match: "session:gitlab:*").to_a).not_to be_empty + end + end + end + + describe '.cleanup' do + it 'removes obsolete lookup entries' do + Gitlab::Redis::SharedState.with do |redis| + redis.set("session:user:gitlab:#{user.id}:6919a6f1bb119dd7396fadc38fd18d0d", '') + redis.sadd("session:lookup:user:gitlab:#{user.id}", '6919a6f1bb119dd7396fadc38fd18d0d') + redis.sadd("session:lookup:user:gitlab:#{user.id}", '59822c7d9fcdfa03725eff41782ad97d') + end + + ActiveSession.cleanup(user) + + Gitlab::Redis::SharedState.with do |redis| + expect(redis.smembers("session:lookup:user:gitlab:#{user.id}")).to eq ['6919a6f1bb119dd7396fadc38fd18d0d'] + end + end + + it 'does not bail if there are no lookup entries' do + ActiveSession.cleanup(user) + end + end +end diff --git a/spec/models/application_setting/term_spec.rb b/spec/models/application_setting/term_spec.rb new file mode 100644 index 00000000000..1eddf3c56ff --- /dev/null +++ b/spec/models/application_setting/term_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' + +describe ApplicationSetting::Term do + describe 'validations' do + it { is_expected.to validate_presence_of(:terms) } + end + + describe '.latest' do + it 'finds the latest terms' do + terms = create(:term) + + expect(described_class.latest).to eq(terms) + end + end +end diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index ae2d34750a7..10d6109cae7 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -301,6 +301,21 @@ describe ApplicationSetting do expect(subject).to be_invalid end end + + describe 'enforcing terms' do + it 'requires the terms to present when enforcing users to accept' do + subject.enforce_terms = true + + expect(subject).to be_invalid + end + + it 'is valid when terms are created' do + create(:term) + subject.enforce_terms = true + + expect(subject).to be_valid + end + end end describe '.current' do diff --git a/spec/models/blob_viewer/readme_spec.rb b/spec/models/blob_viewer/readme_spec.rb index b9946c0315a..8d11d58cfca 100644 --- a/spec/models/blob_viewer/readme_spec.rb +++ b/spec/models/blob_viewer/readme_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe BlobViewer::Readme do include FakeBlobHelpers - let(:project) { create(:project, :repository) } + let(:project) { create(:project, :repository, :wiki_repo) } let(:blob) { fake_blob(path: 'README.md') } subject { described_class.new(blob) } diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 3158e006720..dc810489011 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -1518,7 +1518,10 @@ describe Ci::Build do { key: 'CI_PROJECT_VISIBILITY', value: 'private', public: true }, { key: 'CI_PIPELINE_ID', value: pipeline.id.to_s, public: true }, { key: 'CI_CONFIG_PATH', value: pipeline.ci_yaml_file_path, public: true }, - { key: 'CI_PIPELINE_SOURCE', value: pipeline.source, public: true } + { key: 'CI_PIPELINE_SOURCE', value: pipeline.source, public: true }, + { key: 'CI_COMMIT_MESSAGE', value: pipeline.git_commit_message, public: true }, + { key: 'CI_COMMIT_TITLE', value: pipeline.git_commit_title, public: true }, + { key: 'CI_COMMIT_DESCRIPTION', value: pipeline.git_commit_description, public: true } ] end diff --git a/spec/models/ci/build_trace_chunk_spec.rb b/spec/models/ci/build_trace_chunk_spec.rb new file mode 100644 index 00000000000..cbcf1e55979 --- /dev/null +++ b/spec/models/ci/build_trace_chunk_spec.rb @@ -0,0 +1,396 @@ +require 'spec_helper' + +describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do + set(:build) { create(:ci_build, :running) } + let(:chunk_index) { 0 } + let(:data_store) { :redis } + let(:raw_data) { nil } + + let(:build_trace_chunk) do + described_class.new(build: build, chunk_index: chunk_index, data_store: data_store, raw_data: raw_data) + end + + before do + stub_feature_flags(ci_enable_live_trace: true) + end + + context 'FastDestroyAll' do + let(:parent) { create(:project) } + let(:pipeline) { create(:ci_pipeline, project: parent) } + let(:build) { create(:ci_build, :running, :trace_live, pipeline: pipeline, project: parent) } + let(:subjects) { build.trace_chunks } + + it_behaves_like 'fast destroyable' + + def external_data_counter + Gitlab::Redis::SharedState.with do |redis| + redis.scan_each(match: "gitlab:ci:trace:*:chunks:*").to_a.size + end + end + end + + describe 'CHUNK_SIZE' do + it 'Chunk size can not be changed without special care' do + expect(described_class::CHUNK_SIZE).to eq(128.kilobytes) + end + end + + describe '#data' do + subject { build_trace_chunk.data } + + context 'when data_store is redis' do + let(:data_store) { :redis } + + before do + build_trace_chunk.send(:redis_set_data, 'Sample data in redis') + end + + it { is_expected.to eq('Sample data in redis') } + end + + context 'when data_store is database' do + let(:data_store) { :db } + let(:raw_data) { 'Sample data in db' } + + it { is_expected.to eq('Sample data in db') } + end + + context 'when data_store is others' do + before do + build_trace_chunk.send(:write_attribute, :data_store, -1) + end + + it { expect { subject }.to raise_error('Unsupported data store') } + end + end + + describe '#set_data' do + subject { build_trace_chunk.send(:set_data, value) } + + let(:value) { 'Sample data' } + + context 'when value bytesize is bigger than CHUNK_SIZE' do + let(:value) { 'a' * (described_class::CHUNK_SIZE + 1) } + + it { expect { subject }.to raise_error('too much data') } + end + + context 'when data_store is redis' do + let(:data_store) { :redis } + + it do + expect(build_trace_chunk.send(:redis_data)).to be_nil + + subject + + expect(build_trace_chunk.send(:redis_data)).to eq(value) + end + + context 'when fullfilled chunk size' do + let(:value) { 'a' * described_class::CHUNK_SIZE } + + it 'schedules stashing data' do + expect(Ci::BuildTraceChunkFlushWorker).to receive(:perform_async).once + + subject + end + end + end + + context 'when data_store is database' do + let(:data_store) { :db } + + it 'sets data' do + expect(build_trace_chunk.raw_data).to be_nil + + subject + + expect(build_trace_chunk.raw_data).to eq(value) + expect(build_trace_chunk.persisted?).to be_truthy + end + + context 'when raw_data is not changed' do + it 'does not execute UPDATE' do + expect(build_trace_chunk.raw_data).to be_nil + build_trace_chunk.save! + + # First set + expect(ActiveRecord::QueryRecorder.new { subject }.count).to be > 0 + expect(build_trace_chunk.raw_data).to eq(value) + expect(build_trace_chunk.persisted?).to be_truthy + + # Second set + build_trace_chunk.reload + expect(ActiveRecord::QueryRecorder.new { subject }.count).to be(0) + end + end + + context 'when fullfilled chunk size' do + it 'does not schedule stashing data' do + expect(Ci::BuildTraceChunkFlushWorker).not_to receive(:perform_async) + + subject + end + end + end + + context 'when data_store is others' do + before do + build_trace_chunk.send(:write_attribute, :data_store, -1) + end + + it { expect { subject }.to raise_error('Unsupported data store') } + end + end + + describe '#truncate' do + subject { build_trace_chunk.truncate(offset) } + + shared_examples_for 'truncates' do + context 'when offset is negative' do + let(:offset) { -1 } + + it { expect { subject }.to raise_error('Offset is out of range') } + end + + context 'when offset is bigger than data size' do + let(:offset) { data.bytesize + 1 } + + it { expect { subject }.to raise_error('Offset is out of range') } + end + + context 'when offset is 10' do + let(:offset) { 10 } + + it 'truncates' do + subject + + expect(build_trace_chunk.data).to eq(data.byteslice(0, offset)) + end + end + end + + context 'when data_store is redis' do + let(:data_store) { :redis } + let(:data) { 'Sample data in redis' } + + before do + build_trace_chunk.send(:redis_set_data, data) + end + + it_behaves_like 'truncates' + end + + context 'when data_store is database' do + let(:data_store) { :db } + let(:raw_data) { 'Sample data in db' } + let(:data) { raw_data } + + it_behaves_like 'truncates' + end + end + + describe '#append' do + subject { build_trace_chunk.append(new_data, offset) } + + let(:new_data) { 'Sample new data' } + let(:offset) { 0 } + let(:total_data) { data + new_data } + + shared_examples_for 'appends' do + context 'when offset is negative' do + let(:offset) { -1 } + + it { expect { subject }.to raise_error('Offset is out of range') } + end + + context 'when offset is bigger than data size' do + let(:offset) { data.bytesize + 1 } + + it { expect { subject }.to raise_error('Offset is out of range') } + end + + context 'when offset is bigger than data size' do + let(:new_data) { 'a' * (described_class::CHUNK_SIZE + 1) } + + it { expect { subject }.to raise_error('Chunk size overflow') } + end + + context 'when offset is EOF' do + let(:offset) { data.bytesize } + + it 'appends' do + subject + + expect(build_trace_chunk.data).to eq(total_data) + end + end + + context 'when offset is 10' do + let(:offset) { 10 } + + it 'appends' do + subject + + expect(build_trace_chunk.data).to eq(data.byteslice(0, offset) + new_data) + end + end + end + + context 'when data_store is redis' do + let(:data_store) { :redis } + let(:data) { 'Sample data in redis' } + + before do + build_trace_chunk.send(:redis_set_data, data) + end + + it_behaves_like 'appends' + end + + context 'when data_store is database' do + let(:data_store) { :db } + let(:raw_data) { 'Sample data in db' } + let(:data) { raw_data } + + it_behaves_like 'appends' + end + end + + describe '#size' do + subject { build_trace_chunk.size } + + context 'when data_store is redis' do + let(:data_store) { :redis } + + context 'when data exists' do + let(:data) { 'Sample data in redis' } + + before do + build_trace_chunk.send(:redis_set_data, data) + end + + it { is_expected.to eq(data.bytesize) } + end + + context 'when data exists' do + it { is_expected.to eq(0) } + end + end + + context 'when data_store is database' do + let(:data_store) { :db } + + context 'when data exists' do + let(:raw_data) { 'Sample data in db' } + let(:data) { raw_data } + + it { is_expected.to eq(data.bytesize) } + end + + context 'when data does not exist' do + it { is_expected.to eq(0) } + end + end + end + + describe '#use_database!' do + subject { build_trace_chunk.use_database! } + + context 'when data_store is redis' do + let(:data_store) { :redis } + + context 'when data exists' do + let(:data) { 'Sample data in redis' } + + before do + build_trace_chunk.send(:redis_set_data, data) + end + + it 'stashes the data' do + expect(build_trace_chunk.data_store).to eq('redis') + expect(build_trace_chunk.send(:redis_data)).to eq(data) + expect(build_trace_chunk.raw_data).to be_nil + + subject + + expect(build_trace_chunk.data_store).to eq('db') + expect(build_trace_chunk.send(:redis_data)).to be_nil + expect(build_trace_chunk.raw_data).to eq(data) + end + end + + context 'when data does not exist' do + it 'does not call UPDATE' do + expect(ActiveRecord::QueryRecorder.new { subject }.count).to eq(0) + end + end + end + + context 'when data_store is database' do + let(:data_store) { :db } + + it 'does not call UPDATE' do + expect(ActiveRecord::QueryRecorder.new { subject }.count).to eq(0) + end + end + end + + describe 'ExclusiveLock' do + before do + allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain) { nil } + stub_const('Ci::BuildTraceChunk::WRITE_LOCK_RETRY', 1) + end + + it 'raise an error' do + expect { build_trace_chunk.append('ABC', 0) }.to raise_error('Failed to obtain write lock') + end + end + + describe 'deletes data in redis after a parent record destroyed' do + let(:project) { create(:project) } + + before do + pipeline = create(:ci_pipeline, project: project) + create(:ci_build, :running, :trace_live, pipeline: pipeline, project: project) + create(:ci_build, :running, :trace_live, pipeline: pipeline, project: project) + create(:ci_build, :running, :trace_live, pipeline: pipeline, project: project) + end + + shared_examples_for 'deletes all build_trace_chunk and data in redis' do + it do + Gitlab::Redis::SharedState.with do |redis| + expect(redis.scan_each(match: "gitlab:ci:trace:*:chunks:*").to_a.size).to eq(3) + end + + expect(described_class.count).to eq(3) + + subject + + expect(described_class.count).to eq(0) + + Gitlab::Redis::SharedState.with do |redis| + expect(redis.scan_each(match: "gitlab:ci:trace:*:chunks:*").to_a.size).to eq(0) + end + end + end + + context 'when traces are archived' do + let(:subject) do + project.builds.each do |build| + build.success! + end + end + + it_behaves_like 'deletes all build_trace_chunk and data in redis' + end + + context 'when project is destroyed' do + let(:subject) do + project.destroy! + end + + it_behaves_like 'deletes all build_trace_chunk and data in redis' + end + end +end diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index dd94515b0a4..ddd66a6be87 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -173,7 +173,7 @@ describe Ci::Pipeline, :mailer do it 'includes all predefined variables in a valid order' do keys = subject.map { |variable| variable[:key] } - expect(keys).to eq %w[CI_PIPELINE_ID CI_CONFIG_PATH CI_PIPELINE_SOURCE] + expect(keys).to eq %w[CI_PIPELINE_ID CI_CONFIG_PATH CI_PIPELINE_SOURCE CI_COMMIT_MESSAGE CI_COMMIT_TITLE CI_COMMIT_DESCRIPTION] end end diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index ab170e6351c..cc4d4e5e4ae 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -19,6 +19,63 @@ describe Ci::Runner do end end end + + context 'either_projects_or_group' do + let(:group) { create(:group) } + + it 'disallows assigning to a group if already assigned to a group' do + runner = create(:ci_runner, groups: [group]) + + runner.groups << build(:group) + + expect(runner).not_to be_valid + expect(runner.errors.full_messages).to eq ['Runner can only be assigned to one group'] + end + + it 'disallows assigning to a group if already assigned to a project' do + project = create(:project) + runner = create(:ci_runner, projects: [project]) + + runner.groups << build(:group) + + expect(runner).not_to be_valid + expect(runner.errors.full_messages).to eq ['Runner can only be assigned either to projects or to a group'] + end + + it 'disallows assigning to a project if already assigned to a group' do + runner = create(:ci_runner, groups: [group]) + + runner.projects << build(:project) + + expect(runner).not_to be_valid + expect(runner.errors.full_messages).to eq ['Runner can only be assigned either to projects or to a group'] + end + + it 'allows assigning to a group if not assigned to a group nor a project' do + runner = create(:ci_runner) + + runner.groups << build(:group) + + expect(runner).to be_valid + end + + it 'allows assigning to a project if not assigned to a group nor a project' do + runner = create(:ci_runner) + + runner.projects << build(:project) + + expect(runner).to be_valid + end + + it 'allows assigning to a project if already assigned to a project' do + project = create(:project) + runner = create(:ci_runner, projects: [project]) + + runner.projects << build(:project) + + expect(runner).to be_valid + end + end end describe '#access_level' do @@ -49,6 +106,80 @@ describe Ci::Runner do end end + describe '.shared' do + let(:group) { create(:group) } + let(:project) { create(:project) } + + it 'returns the shared group runner' do + runner = create(:ci_runner, :shared, groups: [group]) + + expect(described_class.shared).to eq [runner] + end + + it 'returns the shared project runner' do + runner = create(:ci_runner, :shared, projects: [project]) + + expect(described_class.shared).to eq [runner] + end + end + + describe '.belonging_to_project' do + it 'returns the specific project runner' do + # own + specific_project = create(:project) + specific_runner = create(:ci_runner, :specific, projects: [specific_project]) + + # other + other_project = create(:project) + create(:ci_runner, :specific, projects: [other_project]) + + expect(described_class.belonging_to_project(specific_project.id)).to eq [specific_runner] + end + end + + describe '.belonging_to_parent_group_of_project' do + let(:project) { create(:project, group: group) } + let(:group) { create(:group) } + let(:runner) { create(:ci_runner, :specific, groups: [group]) } + let!(:unrelated_group) { create(:group) } + let!(:unrelated_project) { create(:project, group: unrelated_group) } + let!(:unrelated_runner) { create(:ci_runner, :specific, groups: [unrelated_group]) } + + it 'returns the specific group runner' do + expect(described_class.belonging_to_parent_group_of_project(project.id)).to contain_exactly(runner) + end + + context 'with a parent group with a runner', :nested_groups do + let(:runner) { create(:ci_runner, :specific, groups: [parent_group]) } + let(:project) { create(:project, group: group) } + let(:group) { create(:group, parent: parent_group) } + let(:parent_group) { create(:group) } + + it 'returns the group runner from the parent group' do + expect(described_class.belonging_to_parent_group_of_project(project.id)).to contain_exactly(runner) + end + end + end + + describe '.owned_or_shared' do + it 'returns a globally shared, a project specific and a group specific runner' do + # group specific + group = create(:group) + project = create(:project, group: group) + group_runner = create(:ci_runner, :specific, groups: [group]) + + # project specific + project_runner = create(:ci_runner, :specific, projects: [project]) + + # globally shared + shared_runner = create(:ci_runner, :shared) + + expect(described_class.owned_or_shared(project.id)).to contain_exactly( + group_runner, project_runner, shared_runner + ) + end + end + describe '#display_name' do it 'returns the description if it has a value' do runner = FactoryBot.build(:ci_runner, description: 'Linux/Ruby-1.9.3-p448') @@ -163,7 +294,9 @@ describe Ci::Runner do describe '#can_pick?' do let(:pipeline) { create(:ci_pipeline) } let(:build) { create(:ci_build, pipeline: pipeline) } - let(:runner) { create(:ci_runner) } + let(:runner) { create(:ci_runner, tag_list: tag_list, run_untagged: run_untagged) } + let(:tag_list) { [] } + let(:run_untagged) { true } subject { runner.can_pick?(build) } @@ -171,6 +304,13 @@ describe Ci::Runner do build.project.runners << runner end + context 'a different runner' do + it 'cannot handle builds' do + other_runner = create(:ci_runner) + expect(other_runner.can_pick?(build)).to be_falsey + end + end + context 'when runner does not have tags' do it 'can handle builds without tags' do expect(runner.can_pick?(build)).to be_truthy @@ -184,9 +324,7 @@ describe Ci::Runner do end context 'when runner has tags' do - before do - runner.tag_list = %w(bb cc) - end + let(:tag_list) { %w(bb cc) } shared_examples 'tagged build picker' do it 'can handle build with matching tags' do @@ -211,9 +349,7 @@ describe Ci::Runner do end context 'when runner cannot pick untagged jobs' do - before do - runner.run_untagged = false - end + let(:run_untagged) { false } it 'cannot handle builds without tags' do expect(runner.can_pick?(build)).to be_falsey @@ -224,8 +360,9 @@ describe Ci::Runner do end context 'when runner is shared' do + let(:runner) { create(:ci_runner, :shared) } + before do - runner.is_shared = true build.project.runners = [] end @@ -234,9 +371,7 @@ describe Ci::Runner do end context 'when runner is locked' do - before do - runner.locked = true - end + let(:runner) { create(:ci_runner, :shared, locked: true) } it 'can handle builds' do expect(runner.can_pick?(build)).to be_truthy @@ -260,6 +395,17 @@ describe Ci::Runner do expect(runner.can_pick?(build)).to be_falsey end end + + context 'when runner is assigned to a group' do + before do + build.project.runners = [] + runner.groups << create(:group, projects: [build.project]) + end + + it 'can handle builds' do + expect(runner.can_pick?(build)).to be_truthy + end + end end context 'when access_level of runner is not_protected' do @@ -583,4 +729,76 @@ describe Ci::Runner do expect(described_class.search(runner.description.upcase)).to eq([runner]) end end + + describe '#assigned_to_group?' do + subject { runner.assigned_to_group? } + + context 'when project runner' do + let(:runner) { create(:ci_runner, description: 'Project runner', projects: [project]) } + let(:project) { create(:project) } + + it { is_expected.to be_falsey } + end + + context 'when shared runner' do + let(:runner) { create(:ci_runner, :shared, description: 'Shared runner') } + + it { is_expected.to be_falsey } + end + + context 'when group runner' do + let(:group) { create(:group) } + let(:runner) { create(:ci_runner, description: 'Group runner', groups: [group]) } + + it { is_expected.to be_truthy } + end + end + + describe '#assigned_to_project?' do + subject { runner.assigned_to_project? } + + context 'when group runner' do + let(:runner) { create(:ci_runner, description: 'Group runner', groups: [group]) } + let(:group) { create(:group) } + it { is_expected.to be_falsey } + end + + context 'when shared runner' do + let(:runner) { create(:ci_runner, :shared, description: 'Shared runner') } + it { is_expected.to be_falsey } + end + + context 'when project runner' do + let(:runner) { create(:ci_runner, description: 'Group runner', projects: [project]) } + let(:project) { create(:project) } + + it { is_expected.to be_truthy } + end + end + + describe '#pick_build!' do + context 'runner can pick the build' do + it 'calls #tick_runner_queue' do + ci_build = build(:ci_build) + runner = build(:ci_runner) + allow(runner).to receive(:can_pick?).with(ci_build).and_return(true) + + expect(runner).to receive(:tick_runner_queue) + + runner.pick_build!(ci_build) + end + end + + context 'runner cannot pick the build' do + it 'does not call #tick_runner_queue' do + ci_build = build(:ci_build) + runner = build(:ci_runner) + allow(runner).to receive(:can_pick?).with(ci_build).and_return(false) + + expect(runner).not_to receive(:tick_runner_queue) + + runner.pick_build!(ci_build) + end + end + end end diff --git a/spec/models/ci/stage_spec.rb b/spec/models/ci/stage_spec.rb index 586d073eb5e..a00db1d2bfc 100644 --- a/spec/models/ci/stage_spec.rb +++ b/spec/models/ci/stage_spec.rb @@ -51,7 +51,7 @@ describe Ci::Stage, :models do end end - describe 'update_status' do + describe '#update_status' do context 'when stage objects needs to be updated' do before do create(:ci_build, :success, stage_id: stage.id) @@ -87,4 +87,36 @@ describe Ci::Stage, :models do end end end + + describe '#index' do + context 'when stage has been imported and does not have position index set' do + before do + stage.update_column(:position, nil) + end + + context 'when stage has statuses' do + before do + create(:ci_build, :running, stage_id: stage.id, stage_idx: 10) + end + + it 'recalculates index before updating status' do + expect(stage.reload.position).to be_nil + + stage.update_status + + expect(stage.reload.position).to eq 10 + end + end + + context 'when stage does not have statuses' do + it 'fallbacks to zero' do + expect(stage.reload.position).to be_nil + + stage.update_status + + expect(stage.reload.position).to eq 0 + end + end + end + end end diff --git a/spec/models/concerns/reactive_caching_spec.rb b/spec/models/concerns/reactive_caching_spec.rb index a5d505af001..4570dbb1d8e 100644 --- a/spec/models/concerns/reactive_caching_spec.rb +++ b/spec/models/concerns/reactive_caching_spec.rb @@ -29,12 +29,6 @@ describe ReactiveCaching, :use_clean_rails_memory_store_caching do end end - let(:now) { Time.now.utc } - - around do |example| - Timecop.freeze(now) { example.run } - end - let(:calculation) { -> { 2 + 2 } } let(:cache_key) { "foo:666" } let(:instance) { CacheTest.new(666, &calculation) } @@ -49,13 +43,15 @@ describe ReactiveCaching, :use_clean_rails_memory_store_caching do context 'when cache is empty' do it { is_expected.to be_nil } - it 'queues a background worker' do + it 'enqueues a background worker to bootstrap the cache' do expect(ReactiveCachingWorker).to receive(:perform_async).with(CacheTest, 666) go! end it 'updates the cache lifespan' do + expect(reactive_cache_alive?(instance)).to be_falsy + go! expect(reactive_cache_alive?(instance)).to be_truthy @@ -69,6 +65,18 @@ describe ReactiveCaching, :use_clean_rails_memory_store_caching do it { is_expected.to eq(2) } + it 'does not enqueue a background worker' do + expect(ReactiveCachingWorker).not_to receive(:perform_async) + + go! + end + + it 'updates the cache lifespan' do + expect(Rails.cache).to receive(:write).with(alive_reactive_cache_key(instance), true, expires_in: anything) + + go! + end + context 'and expired' do before do invalidate_reactive_cache(instance) diff --git a/spec/models/concerns/sha_attribute_spec.rb b/spec/models/concerns/sha_attribute_spec.rb index 21893e0cbaa..592feddf1dc 100644 --- a/spec/models/concerns/sha_attribute_spec.rb +++ b/spec/models/concerns/sha_attribute_spec.rb @@ -13,33 +13,74 @@ describe ShaAttribute do end describe '#sha_attribute' do - context 'when the table exists' do + context 'when in non-production' do before do - allow(model).to receive(:table_exists?).and_return(true) + allow(Rails.env).to receive(:production?).and_return(false) end - it 'defines a SHA attribute for a binary column' do - expect(model).to receive(:attribute) - .with(:sha1, an_instance_of(Gitlab::Database::ShaAttribute)) + context 'when the table exists' do + before do + allow(model).to receive(:table_exists?).and_return(true) + end - model.sha_attribute(:sha1) + it 'defines a SHA attribute for a binary column' do + expect(model).to receive(:attribute) + .with(:sha1, an_instance_of(Gitlab::Database::ShaAttribute)) + + model.sha_attribute(:sha1) + end + + it 'raises ArgumentError when the column type is not :binary' do + expect { model.sha_attribute(:name) }.to raise_error(ArgumentError) + end + end + + context 'when the table does not exist' do + it 'allows the attribute to be added' do + allow(model).to receive(:table_exists?).and_return(false) + + expect(model).not_to receive(:columns) + expect(model).to receive(:attribute) + + model.sha_attribute(:name) + end end - it 'raises ArgumentError when the column type is not :binary' do - expect { model.sha_attribute(:name) }.to raise_error(ArgumentError) + context 'when the column does not exist' do + it 'raises ArgumentError' do + allow(model).to receive(:table_exists?).and_return(true) + + expect(model).to receive(:columns) + expect(model).not_to receive(:attribute) + + expect { model.sha_attribute(:no_name) }.to raise_error(ArgumentError) + end + end + + context 'when other execeptions are raised' do + it 'logs and re-rasises the error' do + allow(model).to receive(:table_exists?).and_raise(ActiveRecord::NoDatabaseError.new('does not exist')) + + expect(model).not_to receive(:columns) + expect(model).not_to receive(:attribute) + expect(Gitlab::AppLogger).to receive(:error) + + expect { model.sha_attribute(:name) }.to raise_error(ActiveRecord::NoDatabaseError) + end end end - context 'when the table does not exist' do + context 'when in production' do before do - allow(model).to receive(:table_exists?).and_return(false) + allow(Rails.env).to receive(:production?).and_return(true) end - it 'does nothing' do + it 'defines a SHA attribute' do + expect(model).not_to receive(:table_exists?) expect(model).not_to receive(:columns) - expect(model).not_to receive(:attribute) + expect(model).to receive(:attribute).with(:sha1, an_instance_of(Gitlab::Database::ShaAttribute)) - model.sha_attribute(:name) + model.sha_attribute(:sha1) end end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index d620943693c..0907d28d33b 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -424,6 +424,95 @@ describe Group do end end + describe '#direct_and_indirect_members', :nested_groups do + let!(:group) { create(:group, :nested) } + let!(:sub_group) { create(:group, parent: group) } + let!(:master) { group.parent.add_user(create(:user), GroupMember::MASTER) } + let!(:developer) { group.add_user(create(:user), GroupMember::DEVELOPER) } + let!(:other_developer) { group.add_user(create(:user), GroupMember::DEVELOPER) } + + it 'returns parents members' do + expect(group.direct_and_indirect_members).to include(developer) + expect(group.direct_and_indirect_members).to include(master) + end + + it 'returns descendant members' do + expect(group.direct_and_indirect_members).to include(other_developer) + end + end + + describe '#users_with_descendants', :nested_groups do + let(:user_a) { create(:user) } + let(:user_b) { create(:user) } + + let(:group) { create(:group) } + let(:nested_group) { create(:group, parent: group) } + let(:deep_nested_group) { create(:group, parent: nested_group) } + + it 'returns member users on every nest level without duplication' do + group.add_developer(user_a) + nested_group.add_developer(user_b) + deep_nested_group.add_developer(user_a) + + expect(group.users_with_descendants).to contain_exactly(user_a, user_b) + expect(nested_group.users_with_descendants).to contain_exactly(user_a, user_b) + expect(deep_nested_group.users_with_descendants).to contain_exactly(user_a) + end + end + + describe '#direct_and_indirect_users', :nested_groups do + let(:user_a) { create(:user) } + let(:user_b) { create(:user) } + let(:user_c) { create(:user) } + let(:user_d) { create(:user) } + + let(:group) { create(:group) } + let(:nested_group) { create(:group, parent: group) } + let(:deep_nested_group) { create(:group, parent: nested_group) } + let(:project) { create(:project, namespace: group) } + + before do + group.add_developer(user_a) + group.add_developer(user_c) + nested_group.add_developer(user_b) + deep_nested_group.add_developer(user_a) + project.add_developer(user_d) + end + + it 'returns member users on every nest level without duplication' do + expect(group.direct_and_indirect_users).to contain_exactly(user_a, user_b, user_c, user_d) + expect(nested_group.direct_and_indirect_users).to contain_exactly(user_a, user_b, user_c) + expect(deep_nested_group.direct_and_indirect_users).to contain_exactly(user_a, user_b, user_c) + end + + it 'does not return members of projects belonging to ancestor groups' do + expect(nested_group.direct_and_indirect_users).not_to include(user_d) + end + end + + describe '#project_users_with_descendants', :nested_groups do + let(:user_a) { create(:user) } + let(:user_b) { create(:user) } + let(:user_c) { create(:user) } + + let(:group) { create(:group) } + let(:nested_group) { create(:group, parent: group) } + let(:deep_nested_group) { create(:group, parent: nested_group) } + let(:project_a) { create(:project, namespace: group) } + let(:project_b) { create(:project, namespace: nested_group) } + let(:project_c) { create(:project, namespace: deep_nested_group) } + + it 'returns members of all projects in group and subgroups' do + project_a.add_developer(user_a) + project_b.add_developer(user_b) + project_c.add_developer(user_c) + + expect(group.project_users_with_descendants).to contain_exactly(user_a, user_b, user_c) + expect(nested_group.project_users_with_descendants).to contain_exactly(user_b, user_c) + expect(deep_nested_group.project_users_with_descendants).to contain_exactly(user_c) + end + end + describe '#user_ids_for_project_authorizations' do it 'returns the user IDs for which to refresh authorizations' do master = create(:user) diff --git a/spec/models/lfs_object_spec.rb b/spec/models/lfs_object_spec.rb index ba06ff42d87..6e35511e848 100644 --- a/spec/models/lfs_object_spec.rb +++ b/spec/models/lfs_object_spec.rb @@ -62,9 +62,7 @@ describe LfsObject do .with('LfsObjectUploader', described_class.name, :file, kind_of(Numeric)) .once - lfs_object = create(:lfs_object) - lfs_object.file = fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "`/png") - lfs_object.save! + create(:lfs_object, :with_file) end end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index becb146422e..04379e7d2c3 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -1069,6 +1069,22 @@ describe MergeRequest do end end + describe '#short_merge_commit_sha' do + let(:merge_request) { build_stubbed(:merge_request) } + + it 'returns short id when there is a merge_commit_sha' do + merge_request.merge_commit_sha = 'f7ce827c314c9340b075657fd61c789fb01cf74d' + + expect(merge_request.short_merge_commit_sha).to eq('f7ce827c') + end + + it 'returns nil when there is no merge_commit_sha' do + merge_request.merge_commit_sha = nil + + expect(merge_request.short_merge_commit_sha).to be_nil + end + end + describe '#can_be_reverted?' do context 'when there is no merge_commit for the MR' do before do @@ -1213,7 +1229,7 @@ describe MergeRequest do it 'enqueues MergeWorker job and updates merge_jid' do merge_request = create(:merge_request) user_id = double(:user_id) - params = double(:params) + params = {} merge_jid = 'hash-123' expect(MergeWorker).to receive(:perform_async).with(merge_request.id, user_id, params) do diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 506057dce87..6f702d8d95e 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -399,6 +399,21 @@ describe Namespace do end end + describe '#self_and_hierarchy', :nested_groups do + let!(:group) { create(:group, path: 'git_lab') } + let!(:nested_group) { create(:group, parent: group) } + let!(:deep_nested_group) { create(:group, parent: nested_group) } + let!(:very_deep_nested_group) { create(:group, parent: deep_nested_group) } + let!(:another_group) { create(:group, path: 'gitllab') } + let!(:another_group_nested) { create(:group, path: 'foo', parent: another_group) } + + it 'returns the correct tree' do + expect(group.self_and_hierarchy).to contain_exactly(group, nested_group, deep_nested_group, very_deep_nested_group) + expect(nested_group.self_and_hierarchy).to contain_exactly(group, nested_group, deep_nested_group, very_deep_nested_group) + expect(very_deep_nested_group.self_and_hierarchy).to contain_exactly(group, nested_group, deep_nested_group, very_deep_nested_group) + end + end + describe '#ancestors', :nested_groups do let(:group) { create(:group) } let(:nested_group) { create(:group, parent: group) } diff --git a/spec/models/notification_setting_spec.rb b/spec/models/notification_setting_spec.rb index 2a0d102d3fe..12681a147b4 100644 --- a/spec/models/notification_setting_spec.rb +++ b/spec/models/notification_setting_spec.rb @@ -40,7 +40,12 @@ RSpec.describe NotificationSetting do expect(notification_setting.new_issue).to eq(true) expect(notification_setting.close_issue).to eq(true) expect(notification_setting.merge_merge_request).to eq(true) - expect(notification_setting.close_merge_request).to eq(false) + + # In Rails 5 assigning a value which is not explicitly `true` or `false` ("nil" in this case) + # to a boolean column transforms it to `true`. + # In Rails 4 it transforms the value to `false` with deprecation warning. + # Replace `eq(Gitlab.rails5?)` with `eq(true)` when removing rails5? code. + expect(notification_setting.close_merge_request).to eq(Gitlab.rails5?) expect(notification_setting.reopen_merge_request).to eq(false) end end diff --git a/spec/models/project_import_state_spec.rb b/spec/models/project_import_state_spec.rb new file mode 100644 index 00000000000..f7033b28c76 --- /dev/null +++ b/spec/models/project_import_state_spec.rb @@ -0,0 +1,13 @@ +require 'rails_helper' + +describe ProjectImportState, type: :model do + subject { create(:import_state) } + + describe 'associations' do + it { is_expected.to belong_to(:project) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:project) } + end +end diff --git a/spec/models/project_services/microsoft_teams_service_spec.rb b/spec/models/project_services/microsoft_teams_service_spec.rb index 733086e258f..8d9ee96227f 100644 --- a/spec/models/project_services/microsoft_teams_service_spec.rb +++ b/spec/models/project_services/microsoft_teams_service_spec.rb @@ -30,7 +30,7 @@ describe MicrosoftTeamsService do describe "#execute" do let(:user) { create(:user) } - let(:project) { create(:project, :repository) } + set(:project) { create(:project, :repository, :wiki_repo) } before do allow(chat_service).to receive_messages( diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index a9587b1005e..a6e835e563d 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -63,7 +63,6 @@ describe Project do it { is_expected.to have_many(:build_trace_section_names)} it { is_expected.to have_many(:runner_projects) } it { is_expected.to have_many(:runners) } - it { is_expected.to have_many(:active_runners) } it { is_expected.to have_many(:variables) } it { is_expected.to have_many(:triggers) } it { is_expected.to have_many(:pages_domains) } @@ -102,6 +101,14 @@ describe Project do end end + context 'updating cd_cd_settings' do + it 'does not raise an error' do + project = create(:project) + + expect { project.update(ci_cd_settings: nil) }.not_to raise_exception + end + end + describe '#members & #requesters' do let(:project) { create(:project, :public, :access_requestable) } let(:requester) { create(:user) } @@ -1139,45 +1146,106 @@ describe Project do end end - describe '#any_runners' do - let(:project) { create(:project, shared_runners_enabled: shared_runners_enabled) } - let(:specific_runner) { create(:ci_runner) } - let(:shared_runner) { create(:ci_runner, :shared) } + describe '#any_runners?' do + context 'shared runners' do + let(:project) { create :project, shared_runners_enabled: shared_runners_enabled } + let(:specific_runner) { create :ci_runner } + let(:shared_runner) { create :ci_runner, :shared } - context 'for shared runners disabled' do - let(:shared_runners_enabled) { false } + context 'for shared runners disabled' do + let(:shared_runners_enabled) { false } - it 'has no runners available' do - expect(project.any_runners?).to be_falsey - end + it 'has no runners available' do + expect(project.any_runners?).to be_falsey + end - it 'has a specific runner' do - project.runners << specific_runner - expect(project.any_runners?).to be_truthy - end + it 'has a specific runner' do + project.runners << specific_runner + + expect(project.any_runners?).to be_truthy + end + + it 'has a shared runner, but they are prohibited to use' do + shared_runner - it 'has a shared runner, but they are prohibited to use' do - shared_runner - expect(project.any_runners?).to be_falsey + expect(project.any_runners?).to be_falsey + end + + it 'checks the presence of specific runner' do + project.runners << specific_runner + + expect(project.any_runners? { |runner| runner == specific_runner }).to be_truthy + end + + it 'returns false if match cannot be found' do + project.runners << specific_runner + + expect(project.any_runners? { false }).to be_falsey + end end - it 'checks the presence of specific runner' do - project.runners << specific_runner - expect(project.any_runners? { |runner| runner == specific_runner }).to be_truthy + context 'for shared runners enabled' do + let(:shared_runners_enabled) { true } + + it 'has a shared runner' do + shared_runner + + expect(project.any_runners?).to be_truthy + end + + it 'checks the presence of shared runner' do + shared_runner + + expect(project.any_runners? { |runner| runner == shared_runner }).to be_truthy + end + + it 'returns false if match cannot be found' do + shared_runner + + expect(project.any_runners? { false }).to be_falsey + end end end - context 'for shared runners enabled' do - let(:shared_runners_enabled) { true } + context 'group runners' do + let(:project) { create :project, group_runners_enabled: group_runners_enabled } + let(:group) { create :group, projects: [project] } + let(:group_runner) { create :ci_runner, groups: [group] } + + context 'for group runners disabled' do + let(:group_runners_enabled) { false } - it 'has a shared runner' do - shared_runner - expect(project.any_runners?).to be_truthy + it 'has no runners available' do + expect(project.any_runners?).to be_falsey + end + + it 'has a group runner, but they are prohibited to use' do + group_runner + + expect(project.any_runners?).to be_falsey + end end - it 'checks the presence of shared runner' do - shared_runner - expect(project.any_runners? { |runner| runner == shared_runner }).to be_truthy + context 'for group runners enabled' do + let(:group_runners_enabled) { true } + + it 'has a group runner' do + group_runner + + expect(project.any_runners?).to be_truthy + end + + it 'checks the presence of group runner' do + group_runner + + expect(project.any_runners? { |runner| runner == group_runner }).to be_truthy + end + + it 'returns false if match cannot be found' do + group_runner + + expect(project.any_runners? { false }).to be_falsey + end end end end @@ -1635,7 +1703,8 @@ describe Project do it 'resets project import_error' do error_message = 'Some error' - mirror = create(:project_empty_repo, :import_started, import_error: error_message) + mirror = create(:project_empty_repo, :import_started) + mirror.import_state.update_attributes(last_error: error_message) expect { mirror.import_finish }.to change { mirror.import_error }.from(error_message).to(nil) end @@ -1783,6 +1852,83 @@ describe Project do it { expect(project.gitea_import?).to be true } end + describe '#has_remote_mirror?' do + let(:project) { create(:project, :remote_mirror, :import_started) } + subject { project.has_remote_mirror? } + + before do + allow_any_instance_of(RemoteMirror).to receive(:refresh_remote) + end + + it 'returns true when a remote mirror is enabled' do + is_expected.to be_truthy + end + + it 'returns false when remote mirror is disabled' do + project.remote_mirrors.first.update_attributes(enabled: false) + + is_expected.to be_falsy + end + end + + describe '#update_remote_mirrors' do + let(:project) { create(:project, :remote_mirror, :import_started) } + delegate :update_remote_mirrors, to: :project + + before do + allow_any_instance_of(RemoteMirror).to receive(:refresh_remote) + end + + it 'syncs enabled remote mirror' do + expect_any_instance_of(RemoteMirror).to receive(:sync) + + update_remote_mirrors + end + + it 'does nothing when remote mirror is disabled globally and not overridden' do + stub_application_setting(mirror_available: false) + project.remote_mirror_available_overridden = false + + expect_any_instance_of(RemoteMirror).not_to receive(:sync) + + update_remote_mirrors + end + + it 'does not sync disabled remote mirrors' do + project.remote_mirrors.first.update_attributes(enabled: false) + + expect_any_instance_of(RemoteMirror).not_to receive(:sync) + + update_remote_mirrors + end + end + + describe '#remote_mirror_available?' do + let(:project) { create(:project) } + + context 'when remote mirror global setting is enabled' do + it 'returns true' do + expect(project.remote_mirror_available?).to be(true) + end + end + + context 'when remote mirror global setting is disabled' do + before do + stub_application_setting(mirror_available: false) + end + + it 'returns true when overridden' do + project.remote_mirror_available_overridden = true + + expect(project.remote_mirror_available?).to be(true) + end + + it 'returns false when not overridden' do + expect(project.remote_mirror_available?).to be(false) + end + end + end + describe '#ancestors_upto', :nested_groups do let(:parent) { create(:group) } let(:child) { create(:group, parent: parent) } @@ -3279,7 +3425,8 @@ describe Project do context 'with an import JID' do it 'unsets the import JID' do - project = create(:project, import_jid: '123') + project = create(:project) + create(:import_state, project: project, jid: '123') expect(Gitlab::SidekiqStatus) .to receive(:unset) @@ -3541,6 +3688,18 @@ describe Project do end end + describe '#toggle_ci_cd_settings!' do + it 'toggles the value on #settings' do + project = create(:project, group_runners_enabled: false) + + expect(project.group_runners_enabled).to be false + + project.toggle_ci_cd_settings!(:group_runners_enabled) + + expect(project.group_runners_enabled).to be true + end + end + describe '#gitlab_deploy_token' do let(:project) { create(:project) } diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb index cbe7d111fcd..d6c4031329d 100644 --- a/spec/models/project_wiki_spec.rb +++ b/spec/models/project_wiki_spec.rb @@ -1,7 +1,7 @@ require "spec_helper" describe ProjectWiki do - let(:project) { create(:project) } + let(:project) { create(:project, :wiki_repo) } let(:repository) { project.repository } let(:user) { project.owner } let(:gitlab_shell) { Gitlab::Shell.new } @@ -328,6 +328,8 @@ describe ProjectWiki do end describe '#create_repo!' do + let(:project) { create(:project) } + it 'creates a repository' do expect(raw_repository.exists?).to eq(false) expect(subject.repository).to receive(:after_create) @@ -339,6 +341,8 @@ describe ProjectWiki do end describe '#ensure_repository' do + let(:project) { create(:project) } + it 'creates the repository if it not exist' do expect(raw_repository.exists?).to eq(false) diff --git a/spec/models/remote_mirror_spec.rb b/spec/models/remote_mirror_spec.rb new file mode 100644 index 00000000000..a80800c6c92 --- /dev/null +++ b/spec/models/remote_mirror_spec.rb @@ -0,0 +1,267 @@ +require 'rails_helper' + +describe RemoteMirror do + describe 'URL validation' do + context 'with a valid URL' do + it 'should be valid' do + remote_mirror = build(:remote_mirror) + expect(remote_mirror).to be_valid + end + end + + context 'with an invalid URL' do + it 'should not be valid' do + remote_mirror = build(:remote_mirror, url: 'ftp://invalid.invalid') + expect(remote_mirror).not_to be_valid + expect(remote_mirror.errors[:url].size).to eq(2) + end + end + end + + describe 'encrypting credentials' do + context 'when setting URL for a first time' do + it 'stores the URL without credentials' do + mirror = create_mirror(url: 'http://foo:bar@test.com') + + expect(mirror.read_attribute(:url)).to eq('http://test.com') + end + + it 'stores the credentials on a separate field' do + mirror = create_mirror(url: 'http://foo:bar@test.com') + + expect(mirror.credentials).to eq({ user: 'foo', password: 'bar' }) + end + + it 'handles credentials with large content' do + mirror = create_mirror(url: 'http://bxnhm8dote33ct932r3xavslj81wxmr7o8yux8do10oozckkif:9ne7fuvjn40qjt35dgt8v86q9m9g9essryxj76sumg2ccl2fg26c0krtz2gzfpyq4hf22h328uhq6npuiq6h53tpagtsj7vsrz75@test.com') + + expect(mirror.credentials).to eq({ + user: 'bxnhm8dote33ct932r3xavslj81wxmr7o8yux8do10oozckkif', + password: '9ne7fuvjn40qjt35dgt8v86q9m9g9essryxj76sumg2ccl2fg26c0krtz2gzfpyq4hf22h328uhq6npuiq6h53tpagtsj7vsrz75' + }) + end + end + + context 'when updating the URL' do + it 'allows a new URL without credentials' do + mirror = create_mirror(url: 'http://foo:bar@test.com') + + mirror.update_attribute(:url, 'http://test.com') + + expect(mirror.url).to eq('http://test.com') + expect(mirror.credentials).to eq({ user: nil, password: nil }) + end + + it 'allows a new URL with credentials' do + mirror = create_mirror(url: 'http://test.com') + + mirror.update_attribute(:url, 'http://foo:bar@test.com') + + expect(mirror.url).to eq('http://foo:bar@test.com') + expect(mirror.credentials).to eq({ user: 'foo', password: 'bar' }) + end + + it 'updates the remote config if credentials changed' do + mirror = create_mirror(url: 'http://foo:bar@test.com') + repo = mirror.project.repository + + mirror.update_attribute(:url, 'http://foo:baz@test.com') + + config = repo.raw_repository.rugged.config + expect(config["remote.#{mirror.remote_name}.url"]).to eq('http://foo:baz@test.com') + end + + it 'removes previous remote' do + mirror = create_mirror(url: 'http://foo:bar@test.com') + + expect(RepositoryRemoveRemoteWorker).to receive(:perform_async).with(mirror.project.id, mirror.remote_name).and_call_original + + mirror.update_attributes(url: 'http://test.com') + end + end + end + + describe '#remote_name' do + context 'when remote name is persisted in the database' do + it 'returns remote name with random value' do + allow(SecureRandom).to receive(:hex).and_return('secret') + + remote_mirror = create(:remote_mirror) + + expect(remote_mirror.remote_name).to eq("remote_mirror_secret") + end + end + + context 'when remote name is not persisted in the database' do + it 'returns remote name with remote mirror id' do + remote_mirror = create(:remote_mirror) + remote_mirror.remote_name = nil + + expect(remote_mirror.remote_name).to eq("remote_mirror_#{remote_mirror.id}") + end + end + + context 'when remote is not persisted in the database' do + it 'returns nil' do + remote_mirror = build(:remote_mirror, remote_name: nil) + + expect(remote_mirror.remote_name).to be_nil + end + end + end + + describe '#safe_url' do + context 'when URL contains credentials' do + it 'masks the credentials' do + mirror = create_mirror(url: 'http://foo:bar@test.com') + + expect(mirror.safe_url).to eq('http://*****:*****@test.com') + end + end + + context 'when URL does not contain credentials' do + it 'shows the full URL' do + mirror = create_mirror(url: 'http://test.com') + + expect(mirror.safe_url).to eq('http://test.com') + end + end + end + + context 'when remote mirror gets destroyed' do + it 'removes remote' do + mirror = create_mirror(url: 'http://foo:bar@test.com') + + expect(RepositoryRemoveRemoteWorker).to receive(:perform_async).with(mirror.project.id, mirror.remote_name).and_call_original + + mirror.destroy! + end + end + + context 'stuck mirrors' do + it 'includes mirrors stuck in started with no last_update_at set' do + mirror = create_mirror(url: 'http://cantbeblank', + update_status: 'started', + last_update_at: nil, + updated_at: 25.hours.ago) + + expect(described_class.stuck.last).to eq(mirror) + end + end + + context '#sync' do + let(:remote_mirror) { create(:project, :repository, :remote_mirror).remote_mirrors.first } + + around do |example| + Timecop.freeze { example.run } + end + + context 'with remote mirroring disabled' do + it 'returns nil' do + remote_mirror.update_attributes(enabled: false) + + expect(remote_mirror.sync).to be_nil + end + end + + context 'with remote mirroring enabled' do + context 'with only protected branches enabled' do + context 'when it did not update in the last minute' do + it 'schedules a RepositoryUpdateRemoteMirrorWorker to run now' do + expect(RepositoryUpdateRemoteMirrorWorker).to receive(:perform_async).with(remote_mirror.id, Time.now) + + remote_mirror.sync + end + end + + context 'when it did update in the last minute' do + it 'schedules a RepositoryUpdateRemoteMirrorWorker to run in the next minute' do + remote_mirror.last_update_started_at = Time.now - 30.seconds + + expect(RepositoryUpdateRemoteMirrorWorker).to receive(:perform_in).with(RemoteMirror::PROTECTED_BACKOFF_DELAY, remote_mirror.id, Time.now) + + remote_mirror.sync + end + end + end + + context 'with only protected branches disabled' do + before do + remote_mirror.only_protected_branches = false + end + + context 'when it did not update in the last 5 minutes' do + it 'schedules a RepositoryUpdateRemoteMirrorWorker to run now' do + expect(RepositoryUpdateRemoteMirrorWorker).to receive(:perform_async).with(remote_mirror.id, Time.now) + + remote_mirror.sync + end + end + + context 'when it did update within the last 5 minutes' do + it 'schedules a RepositoryUpdateRemoteMirrorWorker to run in the next 5 minutes' do + remote_mirror.last_update_started_at = Time.now - 30.seconds + + expect(RepositoryUpdateRemoteMirrorWorker).to receive(:perform_in).with(RemoteMirror::UNPROTECTED_BACKOFF_DELAY, remote_mirror.id, Time.now) + + remote_mirror.sync + end + end + end + end + end + + context '#updated_since?' do + let(:remote_mirror) { create(:project, :repository, :remote_mirror).remote_mirrors.first } + let(:timestamp) { Time.now - 5.minutes } + + around do |example| + Timecop.freeze { example.run } + end + + before do + remote_mirror.update_attributes(last_update_started_at: Time.now) + end + + context 'when remote mirror does not have status failed' do + it 'returns true when last update started after the timestamp' do + expect(remote_mirror.updated_since?(timestamp)).to be true + end + + it 'returns false when last update started before the timestamp' do + expect(remote_mirror.updated_since?(Time.now + 5.minutes)).to be false + end + end + + context 'when remote mirror has status failed' do + it 'returns false when last update started after the timestamp' do + remote_mirror.update_attributes(update_status: 'failed') + + expect(remote_mirror.updated_since?(timestamp)).to be false + end + end + end + + context 'no project' do + it 'includes mirror with a project in pending_delete' do + mirror = create_mirror(url: 'http://cantbeblank', + update_status: 'finished', + enabled: true, + last_update_at: nil, + updated_at: 25.hours.ago) + project = mirror.project + project.pending_delete = true + project.save + mirror.reload + + expect(mirror.sync).to be_nil + expect(mirror.valid?).to be_truthy + expect(mirror.update_status).to eq('finished') + end + end + + def create_mirror(params) + project = FactoryBot.create(:project, :repository) + project.remote_mirrors.create!(params) + end +end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 630b9e0519f..4b736b02b7d 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -758,6 +758,38 @@ describe Repository do end end + describe '#async_remove_remote' do + before do + masterrev = repository.find_branch('master').dereferenced_target + create_remote_branch('joe', 'remote_branch', masterrev) + end + + context 'when worker is scheduled successfully' do + before do + masterrev = repository.find_branch('master').dereferenced_target + create_remote_branch('remote_name', 'remote_branch', masterrev) + + allow(RepositoryRemoveRemoteWorker).to receive(:perform_async).and_return('1234') + end + + it 'returns job_id' do + expect(repository.async_remove_remote('joe')).to eq('1234') + end + end + + context 'when worker does not schedule successfully' do + before do + allow(RepositoryRemoveRemoteWorker).to receive(:perform_async).and_return(nil) + end + + it 'returns nil' do + expect(Rails.logger).to receive(:info).with("Remove remote job failed to create for #{project.id} with remote name joe.") + + expect(repository.async_remove_remote('joe')).to be_nil + end + end + end + describe '#fetch_ref' do let(:broken_repository) { create(:project, :broken_storage).repository } @@ -2338,6 +2370,11 @@ describe Repository do end end + def create_remote_branch(remote_name, branch_name, target) + rugged = repository.rugged + rugged.references.create("refs/remotes/#{remote_name}/#{branch_name}", target.id) + end + describe '#ancestor?' do let(:commit) { repository.commit } let(:ancestor) { commit.parents.first } diff --git a/spec/models/term_agreement_spec.rb b/spec/models/term_agreement_spec.rb new file mode 100644 index 00000000000..a59bf119692 --- /dev/null +++ b/spec/models/term_agreement_spec.rb @@ -0,0 +1,8 @@ +require 'spec_helper' + +describe TermAgreement do + describe 'validations' do + it { is_expected.to validate_presence_of(:term) } + it { is_expected.to validate_presence_of(:user) } + end +end diff --git a/spec/models/wiki_page_spec.rb b/spec/models/wiki_page_spec.rb index 90b7e7715a8..1c765ceac2f 100644 --- a/spec/models/wiki_page_spec.rb +++ b/spec/models/wiki_page_spec.rb @@ -1,7 +1,7 @@ require "spec_helper" describe WikiPage do - let(:project) { create(:project) } + let(:project) { create(:project, :wiki_repo) } let(:user) { project.owner } let(:wiki) { ProjectWiki.new(project, user) } diff --git a/spec/policies/application_setting/term_policy_spec.rb b/spec/policies/application_setting/term_policy_spec.rb new file mode 100644 index 00000000000..93b5ebf5f72 --- /dev/null +++ b/spec/policies/application_setting/term_policy_spec.rb @@ -0,0 +1,50 @@ +require 'spec_helper' + +describe ApplicationSetting::TermPolicy do + include TermsHelper + + set(:term) { create(:term) } + let(:user) { create(:user) } + + subject(:policy) { described_class.new(user, term) } + + before do + stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') + end + + it 'has the correct permissions', :aggregate_failures do + is_expected.to be_allowed(:accept_terms) + is_expected.to be_allowed(:decline_terms) + end + + context 'for anonymous users' do + let(:user) { nil } + + it 'has the correct permissions', :aggregate_failures do + is_expected.to be_disallowed(:accept_terms) + is_expected.to be_disallowed(:decline_terms) + end + end + + context 'when the terms are not current' do + before do + create(:term) + end + + it 'has the correct permissions', :aggregate_failures do + is_expected.to be_disallowed(:accept_terms) + is_expected.to be_disallowed(:decline_terms) + end + end + + context 'when the user already accepted the terms' do + before do + accept_terms(user) + end + + it 'has the correct permissions', :aggregate_failures do + is_expected.to be_disallowed(:accept_terms) + is_expected.to be_allowed(:decline_terms) + end + end +end diff --git a/spec/policies/global_policy_spec.rb b/spec/policies/global_policy_spec.rb index 5b8cf2e6ab5..ec26810e371 100644 --- a/spec/policies/global_policy_spec.rb +++ b/spec/policies/global_policy_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe GlobalPolicy do + include TermsHelper + let(:current_user) { create(:user) } let(:user) { create(:user) } diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb index 6593a6ca3b9..a7a77abc3ee 100644 --- a/spec/policies/user_policy_spec.rb +++ b/spec/policies/user_policy_spec.rb @@ -10,28 +10,36 @@ describe UserPolicy do it { is_expected.to be_allowed(:read_user) } end - describe "destroying a user" do + shared_examples 'changing a user' do |ability| context "when a regular user tries to destroy another regular user" do - it { is_expected.not_to be_allowed(:destroy_user) } + it { is_expected.not_to be_allowed(ability) } end context "when a regular user tries to destroy themselves" do let(:current_user) { user } - it { is_expected.to be_allowed(:destroy_user) } + it { is_expected.to be_allowed(ability) } end context "when an admin user tries to destroy a regular user" do let(:current_user) { create(:user, :admin) } - it { is_expected.to be_allowed(:destroy_user) } + it { is_expected.to be_allowed(ability) } end context "when an admin user tries to destroy a ghost user" do let(:current_user) { create(:user, :admin) } let(:user) { create(:user, :ghost) } - it { is_expected.not_to be_allowed(:destroy_user) } + it { is_expected.not_to be_allowed(ability) } end end + + describe "destroying a user" do + it_behaves_like 'changing a user', :destroy_user + end + + describe "updating a user" do + it_behaves_like 'changing a user', :update_user + end end diff --git a/spec/requests/api/jobs_spec.rb b/spec/requests/api/jobs_spec.rb index 3ffdfdc0e9a..0a2963452e4 100644 --- a/spec/requests/api/jobs_spec.rb +++ b/spec/requests/api/jobs_spec.rb @@ -281,7 +281,7 @@ describe API::Jobs do get_artifact_file(artifact) expect(response).to have_gitlab_http_status(200) - expect(response.headers) + expect(response.headers.to_h) .to include('Content-Type' => 'application/json', 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/) end @@ -311,7 +311,7 @@ describe API::Jobs do it 'returns specific job artifacts' do expect(response).to have_gitlab_http_status(200) - expect(response.headers).to include(download_headers) + expect(response.headers.to_h).to include(download_headers) expect(response.body).to match_file(job.artifacts_file.file.file) end end @@ -462,7 +462,7 @@ describe API::Jobs do end it { expect(response).to have_http_status(:ok) } - it { expect(response.headers).to include(download_headers) } + it { expect(response.headers.to_h).to include(download_headers) } end context 'when artifacts are stored remotely' do diff --git a/spec/requests/api/project_import_spec.rb b/spec/requests/api/project_import_spec.rb index f68057a92a1..f8c64f063af 100644 --- a/spec/requests/api/project_import_spec.rb +++ b/spec/requests/api/project_import_spec.rb @@ -145,7 +145,7 @@ describe API::ProjectImport do describe 'GET /projects/:id/import' do it 'returns the import status' do - project = create(:project, import_status: 'started') + project = create(:project, :import_started) project.add_master(user) get api("/projects/#{project.id}/import", user) @@ -155,8 +155,9 @@ describe API::ProjectImport do end it 'returns the import status and the error if failed' do - project = create(:project, import_status: 'failed', import_error: 'error') + project = create(:project, :import_failed) project.add_master(user) + project.import_state.update_attributes(last_error: 'error') get api("/projects/#{project.id}/import", user) diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb index 17c7a511857..082605827b7 100644 --- a/spec/requests/api/runner_spec.rb +++ b/spec/requests/api/runner_spec.rb @@ -1,11 +1,13 @@ require 'spec_helper' -describe API::Runner do +describe API::Runner, :clean_gitlab_redis_shared_state do include StubGitlabCalls + include RedisHelpers let(:registration_token) { 'abcdefg123456' } before do + stub_feature_flags(ci_enable_live_trace: true) stub_gitlab_calls stub_application_setting(runners_registration_token: registration_token) allow_any_instance_of(Ci::Runner).to receive(:cache_attributes) @@ -40,18 +42,36 @@ describe API::Runner do expect(json_response['token']).to eq(runner.token) expect(runner.run_untagged).to be true expect(runner.token).not_to eq(registration_token) + expect(runner).to be_instance_type end context 'when project token is used' do let(:project) { create(:project) } - it 'creates runner' do + it 'creates project runner' do post api('/runners'), token: project.runners_token expect(response).to have_gitlab_http_status 201 expect(project.runners.size).to eq(1) - expect(Ci::Runner.first.token).not_to eq(registration_token) - expect(Ci::Runner.first.token).not_to eq(project.runners_token) + runner = Ci::Runner.first + expect(runner.token).not_to eq(registration_token) + expect(runner.token).not_to eq(project.runners_token) + expect(runner).to be_project_type + end + end + + context 'when group token is used' do + let(:group) { create(:group) } + + it 'creates a group runner' do + post api('/runners'), token: group.runners_token + + expect(response).to have_http_status 201 + expect(group.runners.size).to eq(1) + runner = Ci::Runner.first + expect(runner.token).not_to eq(registration_token) + expect(runner.token).not_to eq(group.runners_token) + expect(runner).to be_group_type end end end @@ -864,6 +884,49 @@ describe API::Runner do expect(response.status).to eq(403) end end + + context 'when trace is patched' do + before do + patch_the_trace + end + + it 'has valid trace' do + expect(response.status).to eq(202) + expect(job.reload.trace.raw).to eq 'BUILD TRACE appended appended' + end + + context 'when redis data are flushed' do + before do + redis_shared_state_cleanup! + end + + it 'has empty trace' do + expect(job.reload.trace.raw).to eq '' + end + + context 'when we perform partial patch' do + before do + patch_the_trace('hello', headers.merge({ 'Content-Range' => "28-32/5" })) + end + + it 'returns an error' do + expect(response.status).to eq(416) + expect(response.header['Range']).to eq('0-0') + end + end + + context 'when we resend full trace' do + before do + patch_the_trace('BUILD TRACE appended appended hello', headers.merge({ 'Content-Range' => "0-34/35" })) + end + + it 'succeeds with updating trace' do + expect(response.status).to eq(202) + expect(job.reload.trace.raw).to eq 'BUILD TRACE appended appended hello' + end + end + end + end end context 'when Runner makes a force-patch' do @@ -880,7 +943,7 @@ describe API::Runner do end context 'when content-range start is too big' do - let(:headers_with_range) { headers.merge({ 'Content-Range' => '15-20' }) } + let(:headers_with_range) { headers.merge({ 'Content-Range' => '15-20/6' }) } it 'gets 416 error response with range headers' do expect(response.status).to eq 416 @@ -890,7 +953,7 @@ describe API::Runner do end context 'when content-range start is too small' do - let(:headers_with_range) { headers.merge({ 'Content-Range' => '8-20' }) } + let(:headers_with_range) { headers.merge({ 'Content-Range' => '8-20/13' }) } it 'gets 416 error response with range headers' do expect(response.status).to eq 416 @@ -1330,7 +1393,7 @@ describe API::Runner do it 'download artifacts' do expect(response).to have_http_status(200) - expect(response.headers).to include download_headers + expect(response.headers.to_h).to include download_headers end end @@ -1345,7 +1408,7 @@ describe API::Runner do it 'uses workhorse send-url' do expect(response).to have_gitlab_http_status(200) - expect(response.headers).to include( + expect(response.headers.to_h).to include( 'Gitlab-Workhorse-Send-Data' => /send-url:/) end end diff --git a/spec/requests/api/runners_spec.rb b/spec/requests/api/runners_spec.rb index d30f0cf36e2..981ac768e3a 100644 --- a/spec/requests/api/runners_spec.rb +++ b/spec/requests/api/runners_spec.rb @@ -8,22 +8,27 @@ describe API::Runners do let(:project) { create(:project, creator_id: user.id) } let(:project2) { create(:project, creator_id: user.id) } - let!(:shared_runner) { create(:ci_runner, :shared) } - let!(:unused_specific_runner) { create(:ci_runner) } + let(:group) { create(:group).tap { |group| group.add_owner(user) } } + let(:group2) { create(:group).tap { |group| group.add_owner(user) } } - let!(:specific_runner) do - create(:ci_runner).tap do |runner| + let!(:shared_runner) { create(:ci_runner, :shared, description: 'Shared runner') } + let!(:unused_project_runner) { create(:ci_runner) } + + let!(:project_runner) do + create(:ci_runner, description: 'Project runner').tap do |runner| create(:ci_runner_project, runner: runner, project: project) end end let!(:two_projects_runner) do - create(:ci_runner).tap do |runner| + create(:ci_runner, description: 'Two projects runner').tap do |runner| create(:ci_runner_project, runner: runner, project: project) create(:ci_runner_project, runner: runner, project: project2) end end + let!(:group_runner) { create(:ci_runner, description: 'Group runner', groups: [group]) } + before do # Set project access for users create(:project_member, :master, user: user, project: project) @@ -37,9 +42,14 @@ describe API::Runners do get api('/runners', user) shared = json_response.any? { |r| r['is_shared'] } + descriptions = json_response.map { |runner| runner['description'] } expect(response).to have_gitlab_http_status(200) expect(response).to include_pagination_headers expect(json_response).to be_an Array + expect(json_response[0]).to have_key('ip_address') + expect(descriptions).to contain_exactly( + 'Project runner', 'Two projects runner' + ) expect(shared).to be_falsey end @@ -50,6 +60,7 @@ describe API::Runners do expect(response).to have_gitlab_http_status(200) expect(response).to include_pagination_headers expect(json_response).to be_an Array + expect(json_response[0]).to have_key('ip_address') expect(shared).to be_falsey end @@ -78,6 +89,7 @@ describe API::Runners do expect(response).to have_gitlab_http_status(200) expect(response).to include_pagination_headers expect(json_response).to be_an Array + expect(json_response[0]).to have_key('ip_address') expect(shared).to be_truthy end end @@ -97,6 +109,7 @@ describe API::Runners do expect(response).to have_gitlab_http_status(200) expect(response).to include_pagination_headers expect(json_response).to be_an Array + expect(json_response[0]).to have_key('ip_address') expect(shared).to be_falsey end @@ -129,10 +142,16 @@ describe API::Runners do context 'when runner is not shared' do it "returns runner's details" do - get api("/runners/#{specific_runner.id}", admin) + get api("/runners/#{project_runner.id}", admin) expect(response).to have_gitlab_http_status(200) - expect(json_response['description']).to eq(specific_runner.description) + expect(json_response['description']).to eq(project_runner.description) + end + + it "returns the project's details for a project runner" do + get api("/runners/#{project_runner.id}", admin) + + expect(json_response['projects'].first['id']).to eq(project.id) end end @@ -146,10 +165,10 @@ describe API::Runners do context "runner project's administrative user" do context 'when runner is not shared' do it "returns runner's details" do - get api("/runners/#{specific_runner.id}", user) + get api("/runners/#{project_runner.id}", user) expect(response).to have_gitlab_http_status(200) - expect(json_response['description']).to eq(specific_runner.description) + expect(json_response['description']).to eq(project_runner.description) end end @@ -164,18 +183,18 @@ describe API::Runners do end context 'other authorized user' do - it "does not return runner's details" do - get api("/runners/#{specific_runner.id}", user2) + it "does not return project runner's details" do + get api("/runners/#{project_runner.id}", user2) - expect(response).to have_gitlab_http_status(403) + expect(response).to have_http_status(403) end end context 'unauthorized user' do - it "does not return runner's details" do - get api("/runners/#{specific_runner.id}") + it "does not return project runner's details" do + get api("/runners/#{project_runner.id}") - expect(response).to have_gitlab_http_status(401) + expect(response).to have_http_status(401) end end end @@ -212,16 +231,16 @@ describe API::Runners do context 'when runner is not shared' do it 'updates runner' do - description = specific_runner.description - runner_queue_value = specific_runner.ensure_runner_queue_value + description = project_runner.description + runner_queue_value = project_runner.ensure_runner_queue_value - update_runner(specific_runner.id, admin, description: 'test') - specific_runner.reload + update_runner(project_runner.id, admin, description: 'test') + project_runner.reload expect(response).to have_gitlab_http_status(200) - expect(specific_runner.description).to eq('test') - expect(specific_runner.description).not_to eq(description) - expect(specific_runner.ensure_runner_queue_value) + expect(project_runner.description).to eq('test') + expect(project_runner.description).not_to eq(description) + expect(project_runner.ensure_runner_queue_value) .not_to eq(runner_queue_value) end end @@ -247,29 +266,29 @@ describe API::Runners do end context 'when runner is not shared' do - it 'does not update runner without access to it' do - put api("/runners/#{specific_runner.id}", user2), description: 'test' + it 'does not update project runner without access to it' do + put api("/runners/#{project_runner.id}", user2), description: 'test' - expect(response).to have_gitlab_http_status(403) + expect(response).to have_http_status(403) end - it 'updates runner with access to it' do - description = specific_runner.description - put api("/runners/#{specific_runner.id}", admin), description: 'test' - specific_runner.reload + it 'updates project runner with access to it' do + description = project_runner.description + put api("/runners/#{project_runner.id}", admin), description: 'test' + project_runner.reload expect(response).to have_gitlab_http_status(200) - expect(specific_runner.description).to eq('test') - expect(specific_runner.description).not_to eq(description) + expect(project_runner.description).to eq('test') + expect(project_runner.description).not_to eq(description) end end end context 'unauthorized user' do - it 'does not delete runner' do - put api("/runners/#{specific_runner.id}") + it 'does not delete project runner' do + put api("/runners/#{project_runner.id}") - expect(response).to have_gitlab_http_status(401) + expect(response).to have_http_status(401) end end end @@ -293,17 +312,17 @@ describe API::Runners do context 'when runner is not shared' do it 'deletes unused runner' do expect do - delete api("/runners/#{unused_specific_runner.id}", admin) + delete api("/runners/#{unused_project_runner.id}", admin) expect(response).to have_gitlab_http_status(204) end.to change { Ci::Runner.specific.count }.by(-1) end - it 'deletes used runner' do + it 'deletes used project runner' do expect do - delete api("/runners/#{specific_runner.id}", admin) + delete api("/runners/#{project_runner.id}", admin) - expect(response).to have_gitlab_http_status(204) + expect(response).to have_http_status(204) end.to change { Ci::Runner.specific.count }.by(-1) end end @@ -325,34 +344,34 @@ describe API::Runners do context 'when runner is not shared' do it 'does not delete runner without access to it' do - delete api("/runners/#{specific_runner.id}", user2) + delete api("/runners/#{project_runner.id}", user2) expect(response).to have_gitlab_http_status(403) end - it 'does not delete runner with more than one associated project' do + it 'does not delete project runner with more than one associated project' do delete api("/runners/#{two_projects_runner.id}", user) expect(response).to have_gitlab_http_status(403) end - it 'deletes runner for one owned project' do + it 'deletes project runner for one owned project' do expect do - delete api("/runners/#{specific_runner.id}", user) + delete api("/runners/#{project_runner.id}", user) - expect(response).to have_gitlab_http_status(204) + expect(response).to have_http_status(204) end.to change { Ci::Runner.specific.count }.by(-1) end it_behaves_like '412 response' do - let(:request) { api("/runners/#{specific_runner.id}", user) } + let(:request) { api("/runners/#{project_runner.id}", user) } end end end context 'unauthorized user' do - it 'does not delete runner' do - delete api("/runners/#{specific_runner.id}") + it 'does not delete project runner' do + delete api("/runners/#{project_runner.id}") - expect(response).to have_gitlab_http_status(401) + expect(response).to have_http_status(401) end end end @@ -361,8 +380,8 @@ describe API::Runners do set(:job_1) { create(:ci_build) } let!(:job_2) { create(:ci_build, :running, runner: shared_runner, project: project) } let!(:job_3) { create(:ci_build, :failed, runner: shared_runner, project: project) } - let!(:job_4) { create(:ci_build, :running, runner: specific_runner, project: project) } - let!(:job_5) { create(:ci_build, :failed, runner: specific_runner, project: project) } + let!(:job_4) { create(:ci_build, :running, runner: project_runner, project: project) } + let!(:job_5) { create(:ci_build, :failed, runner: project_runner, project: project) } context 'admin user' do context 'when runner exists' do @@ -380,7 +399,7 @@ describe API::Runners do context 'when runner is specific' do it 'return jobs' do - get api("/runners/#{specific_runner.id}/jobs", admin) + get api("/runners/#{project_runner.id}/jobs", admin) expect(response).to have_gitlab_http_status(200) expect(response).to include_pagination_headers @@ -392,7 +411,7 @@ describe API::Runners do context 'when valid status is provided' do it 'return filtered jobs' do - get api("/runners/#{specific_runner.id}/jobs?status=failed", admin) + get api("/runners/#{project_runner.id}/jobs?status=failed", admin) expect(response).to have_gitlab_http_status(200) expect(response).to include_pagination_headers @@ -405,7 +424,7 @@ describe API::Runners do context 'when invalid status is provided' do it 'return 400' do - get api("/runners/#{specific_runner.id}/jobs?status=non-existing", admin) + get api("/runners/#{project_runner.id}/jobs?status=non-existing", admin) expect(response).to have_gitlab_http_status(400) end @@ -433,7 +452,7 @@ describe API::Runners do context 'when runner is specific' do it 'return jobs' do - get api("/runners/#{specific_runner.id}/jobs", user) + get api("/runners/#{project_runner.id}/jobs", user) expect(response).to have_gitlab_http_status(200) expect(response).to include_pagination_headers @@ -445,7 +464,7 @@ describe API::Runners do context 'when valid status is provided' do it 'return filtered jobs' do - get api("/runners/#{specific_runner.id}/jobs?status=failed", user) + get api("/runners/#{project_runner.id}/jobs?status=failed", user) expect(response).to have_gitlab_http_status(200) expect(response).to include_pagination_headers @@ -458,7 +477,7 @@ describe API::Runners do context 'when invalid status is provided' do it 'return 400' do - get api("/runners/#{specific_runner.id}/jobs?status=non-existing", user) + get api("/runners/#{project_runner.id}/jobs?status=non-existing", user) expect(response).to have_gitlab_http_status(400) end @@ -476,7 +495,7 @@ describe API::Runners do context 'other authorized user' do it 'does not return jobs' do - get api("/runners/#{specific_runner.id}/jobs", user2) + get api("/runners/#{project_runner.id}/jobs", user2) expect(response).to have_gitlab_http_status(403) end @@ -484,7 +503,7 @@ describe API::Runners do context 'unauthorized user' do it 'does not return jobs' do - get api("/runners/#{specific_runner.id}/jobs") + get api("/runners/#{project_runner.id}/jobs") expect(response).to have_gitlab_http_status(401) end @@ -500,6 +519,7 @@ describe API::Runners do expect(response).to have_gitlab_http_status(200) expect(response).to include_pagination_headers expect(json_response).to be_an Array + expect(json_response[0]).to have_key('ip_address') expect(shared).to be_truthy end end @@ -523,7 +543,7 @@ describe API::Runners do describe 'POST /projects/:id/runners' do context 'authorized user' do - let(:specific_runner2) do + let(:project_runner2) do create(:ci_runner).tap do |runner| create(:ci_runner_project, runner: runner, project: project2) end @@ -531,23 +551,23 @@ describe API::Runners do it 'enables specific runner' do expect do - post api("/projects/#{project.id}/runners", user), runner_id: specific_runner2.id + post api("/projects/#{project.id}/runners", user), runner_id: project_runner2.id end.to change { project.runners.count }.by(+1) expect(response).to have_gitlab_http_status(201) end it 'avoids changes when enabling already enabled runner' do expect do - post api("/projects/#{project.id}/runners", user), runner_id: specific_runner.id + post api("/projects/#{project.id}/runners", user), runner_id: project_runner.id end.to change { project.runners.count }.by(0) expect(response).to have_gitlab_http_status(409) end it 'does not enable locked runner' do - specific_runner2.update(locked: true) + project_runner2.update(locked: true) expect do - post api("/projects/#{project.id}/runners", user), runner_id: specific_runner2.id + post api("/projects/#{project.id}/runners", user), runner_id: project_runner2.id end.to change { project.runners.count }.by(0) expect(response).to have_gitlab_http_status(403) @@ -559,10 +579,16 @@ describe API::Runners do expect(response).to have_gitlab_http_status(403) end + it 'does not enable group runner' do + post api("/projects/#{project.id}/runners", user), runner_id: group_runner.id + + expect(response).to have_http_status(403) + end + context 'user is admin' do it 'enables any specific runner' do expect do - post api("/projects/#{project.id}/runners", admin), runner_id: unused_specific_runner.id + post api("/projects/#{project.id}/runners", admin), runner_id: unused_project_runner.id end.to change { project.runners.count }.by(+1) expect(response).to have_gitlab_http_status(201) end @@ -570,7 +596,7 @@ describe API::Runners do context 'user is not admin' do it 'does not enable runner without access to' do - post api("/projects/#{project.id}/runners", user), runner_id: unused_specific_runner.id + post api("/projects/#{project.id}/runners", user), runner_id: unused_project_runner.id expect(response).to have_gitlab_http_status(403) end @@ -619,7 +645,7 @@ describe API::Runners do context 'when runner have one associated projects' do it "does not disable project's runner" do expect do - delete api("/projects/#{project.id}/runners/#{specific_runner.id}", user) + delete api("/projects/#{project.id}/runners/#{project_runner.id}", user) end.to change { project.runners.count }.by(0) expect(response).to have_gitlab_http_status(403) end @@ -634,7 +660,7 @@ describe API::Runners do context 'authorized user without permissions' do it "does not disable project's runner" do - delete api("/projects/#{project.id}/runners/#{specific_runner.id}", user2) + delete api("/projects/#{project.id}/runners/#{project_runner.id}", user2) expect(response).to have_gitlab_http_status(403) end @@ -642,7 +668,7 @@ describe API::Runners do context 'unauthorized user' do it "does not disable project's runner" do - delete api("/projects/#{project.id}/runners/#{specific_runner.id}") + delete api("/projects/#{project.id}/runners/#{project_runner.id}") expect(response).to have_gitlab_http_status(401) end diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb index f8d5258a8d9..aca4aa40027 100644 --- a/spec/requests/api/search_spec.rb +++ b/spec/requests/api/search_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe API::Search do set(:user) { create(:user) } set(:group) { create(:group) } - set(:project) { create(:project, :public, name: 'awesome project', group: group) } + set(:project) { create(:project, :wiki_repo, :public, name: 'awesome project', group: group) } set(:repo_project) { create(:project, :public, :repository, group: group) } shared_examples 'response is correct' do |schema:, size: 1| diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb index 015d4b9a491..8b22d1e72f3 100644 --- a/spec/requests/api/settings_spec.rb +++ b/spec/requests/api/settings_spec.rb @@ -54,7 +54,9 @@ describe API::Settings, 'Settings' do dsa_key_restriction: 2048, ecdsa_key_restriction: 384, ed25519_key_restriction: 256, - circuitbreaker_check_interval: 2 + circuitbreaker_check_interval: 2, + enforce_terms: true, + terms: 'Hello world!' expect(response).to have_gitlab_http_status(200) expect(json_response['default_projects_limit']).to eq(3) @@ -76,6 +78,8 @@ describe API::Settings, 'Settings' do expect(json_response['ecdsa_key_restriction']).to eq(384) expect(json_response['ed25519_key_restriction']).to eq(256) expect(json_response['circuitbreaker_check_interval']).to eq(2) + expect(json_response['enforce_terms']).to be(true) + expect(json_response['terms']).to eq('Hello world!') end end diff --git a/spec/requests/api/v3/builds_spec.rb b/spec/requests/api/v3/builds_spec.rb index 00f067889a0..485d7c2cc43 100644 --- a/spec/requests/api/v3/builds_spec.rb +++ b/spec/requests/api/v3/builds_spec.rb @@ -232,7 +232,7 @@ describe API::V3::Builds do it 'returns specific job artifacts' do expect(response).to have_http_status(200) - expect(response.headers).to include(download_headers) + expect(response.headers.to_h).to include(download_headers) expect(response.body).to match_file(build.artifacts_file.file.file) end end @@ -332,7 +332,7 @@ describe API::V3::Builds do end it { expect(response).to have_http_status(200) } - it { expect(response.headers).to include(download_headers) } + it { expect(response.headers.to_h).to include(download_headers) } end context 'when artifacts are stored remotely' do diff --git a/spec/requests/api/wikis_spec.rb b/spec/requests/api/wikis_spec.rb index fb0806ff9f1..850ba696098 100644 --- a/spec/requests/api/wikis_spec.rb +++ b/spec/requests/api/wikis_spec.rb @@ -143,7 +143,7 @@ describe API::Wikis do let(:url) { "/projects/#{project.id}/wikis" } context 'when wiki is disabled' do - let(:project) { create(:project, :wiki_disabled) } + let(:project) { create(:project, :wiki_repo, :wiki_disabled) } context 'when user is guest' do before do @@ -175,7 +175,7 @@ describe API::Wikis do end context 'when wiki is available only for team members' do - let(:project) { create(:project, :wiki_private) } + let(:project) { create(:project, :wiki_repo, :wiki_private) } context 'when user is guest' do before do @@ -203,7 +203,7 @@ describe API::Wikis do end context 'when wiki is available for everyone with access' do - let(:project) { create(:project) } + let(:project) { create(:project, :wiki_repo) } context 'when user is guest' do before do @@ -236,7 +236,7 @@ describe API::Wikis do let(:url) { "/projects/#{project.id}/wikis/#{page.slug}" } context 'when wiki is disabled' do - let(:project) { create(:project, :wiki_disabled) } + let(:project) { create(:project, :wiki_repo, :wiki_disabled) } context 'when user is guest' do before do @@ -268,7 +268,7 @@ describe API::Wikis do end context 'when wiki is available only for team members' do - let(:project) { create(:project, :wiki_private) } + let(:project) { create(:project, :wiki_repo, :wiki_private) } context 'when user is guest' do before do @@ -311,7 +311,7 @@ describe API::Wikis do end context 'when wiki is available for everyone with access' do - let(:project) { create(:project) } + let(:project) { create(:project, :wiki_repo) } context 'when user is guest' do before do @@ -360,7 +360,7 @@ describe API::Wikis do let(:url) { "/projects/#{project.id}/wikis" } context 'when wiki is disabled' do - let(:project) { create(:project, :wiki_disabled) } + let(:project) { create(:project, :wiki_disabled, :wiki_repo) } context 'when user is guest' do before do @@ -390,7 +390,7 @@ describe API::Wikis do end context 'when wiki is available only for team members' do - let(:project) { create(:project, :wiki_private) } + let(:project) { create(:project, :wiki_private, :wiki_repo) } context 'when user is guest' do before do @@ -418,7 +418,7 @@ describe API::Wikis do end context 'when wiki is available for everyone with access' do - let(:project) { create(:project) } + let(:project) { create(:project, :wiki_repo) } context 'when user is guest' do before do @@ -452,7 +452,7 @@ describe API::Wikis do let(:url) { "/projects/#{project.id}/wikis/#{page.slug}" } context 'when wiki is disabled' do - let(:project) { create(:project, :wiki_disabled) } + let(:project) { create(:project, :wiki_disabled, :wiki_repo) } context 'when user is guest' do before do @@ -484,7 +484,7 @@ describe API::Wikis do end context 'when wiki is available only for team members' do - let(:project) { create(:project, :wiki_private) } + let(:project) { create(:project, :wiki_private, :wiki_repo) } context 'when user is guest' do before do @@ -528,7 +528,7 @@ describe API::Wikis do end context 'when wiki is available for everyone with access' do - let(:project) { create(:project) } + let(:project) { create(:project, :wiki_repo) } context 'when user is guest' do before do @@ -572,7 +572,7 @@ describe API::Wikis do end context 'when wiki belongs to a group project' do - let(:project) { create(:project, namespace: group) } + let(:project) { create(:project, :wiki_repo, namespace: group) } before do put(api(url, user), payload) @@ -587,7 +587,7 @@ describe API::Wikis do let(:url) { "/projects/#{project.id}/wikis/#{page.slug}" } context 'when wiki is disabled' do - let(:project) { create(:project, :wiki_disabled) } + let(:project) { create(:project, :wiki_disabled, :wiki_repo) } context 'when user is guest' do before do @@ -619,7 +619,7 @@ describe API::Wikis do end context 'when wiki is available only for team members' do - let(:project) { create(:project, :wiki_private) } + let(:project) { create(:project, :wiki_private, :wiki_repo) } context 'when user is guest' do before do @@ -651,7 +651,7 @@ describe API::Wikis do end context 'when wiki is available for everyone with access' do - let(:project) { create(:project) } + let(:project) { create(:project, :wiki_repo) } context 'when user is guest' do before do @@ -689,7 +689,7 @@ describe API::Wikis do end context 'when wiki belongs to a group project' do - let(:project) { create(:project, namespace: group) } + let(:project) { create(:project, :wiki_repo, namespace: group) } before do delete(api(url, user)) diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb index f51c11b141f..e88e86c2998 100644 --- a/spec/serializers/pipeline_serializer_spec.rb +++ b/spec/serializers/pipeline_serializer_spec.rb @@ -118,7 +118,7 @@ describe PipelineSerializer do it 'verifies number of queries', :request_store do recorded = ActiveRecord::QueryRecorder.new { subject } - expect(recorded.count).to be_within(1).of(36) + expect(recorded.count).to be_within(1).of(44) expect(recorded.cached_count).to eq(0) end end diff --git a/spec/services/application_settings/update_service_spec.rb b/spec/services/application_settings/update_service_spec.rb new file mode 100644 index 00000000000..fb07ecc6ae8 --- /dev/null +++ b/spec/services/application_settings/update_service_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' + +describe ApplicationSettings::UpdateService do + let(:application_settings) { Gitlab::CurrentSettings.current_application_settings } + let(:admin) { create(:user, :admin) } + let(:params) { {} } + + subject { described_class.new(application_settings, admin, params) } + + before do + # So the caching behaves like it would in production + stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') + end + + describe 'updating terms' do + context 'when the passed terms are blank' do + let(:params) { { terms: '' } } + + it 'does not create terms' do + expect { subject.execute }.not_to change { ApplicationSetting::Term.count } + end + end + + context 'when passing terms' do + let(:params) { { terms: 'Be nice! ' } } + + it 'creates the terms' do + expect { subject.execute }.to change { ApplicationSetting::Term.count }.by(1) + end + + it 'does not create terms if they are the same as the existing ones' do + create(:term, terms: 'Be nice!') + + expect { subject.execute }.not_to change { ApplicationSetting::Term.count } + end + + it 'updates terms if they already existed' do + create(:term, terms: 'Other terms') + + subject.execute + + expect(application_settings.terms).to eq('Be nice!') + end + + it 'Only queries once when the terms are changed' do + create(:term, terms: 'Other terms') + expect(application_settings.terms).to eq('Other terms') + + subject.execute + + expect(application_settings.terms).to eq('Be nice!') + expect { 2.times { application_settings.terms } } + .not_to exceed_query_limit(0) + end + end + end +end diff --git a/spec/services/applications/create_service_spec.rb b/spec/services/applications/create_service_spec.rb index 47a2a9d6403..9c43b56744b 100644 --- a/spec/services/applications/create_service_spec.rb +++ b/spec/services/applications/create_service_spec.rb @@ -1,13 +1,17 @@ -require 'spec_helper' +require "spec_helper" describe ::Applications::CreateService do let(:user) { create(:user) } let(:params) { attributes_for(:application) } - let(:request) { ActionController::TestRequest.new(remote_ip: '127.0.0.1') } + let(:request) do + if Gitlab.rails5? + ActionController::TestRequest.new({ remote_ip: "127.0.0.1" }, ActionController::TestSession.new) + else + ActionController::TestRequest.new(remote_ip: "127.0.0.1") + end + end subject { described_class.new(user, params) } - it 'creates an application' do - expect { subject.execute(request) }.to change { Doorkeeper::Application.count }.by(1) - end + it { expect { subject.execute(request) }.to change { Doorkeeper::Application.count }.by(1) } end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index 267258b33a8..9a0b6efd8a9 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -17,11 +17,13 @@ describe Ci::CreatePipelineService do after: project.commit.id, message: 'Message', ref: ref_name, - trigger_request: nil) + trigger_request: nil, + variables_attributes: nil) params = { ref: ref, before: '00000000', after: after, - commits: [{ message: message }] } + commits: [{ message: message }], + variables_attributes: variables_attributes } described_class.new(project, user, params).execute( source, trigger_request: trigger_request) @@ -545,5 +547,19 @@ describe Ci::CreatePipelineService do expect(pipeline.tag?).to be true end end + + context 'when pipeline variables are specified' do + let(:variables_attributes) do + [{ key: 'first', secret_value: 'world' }, + { key: 'second', secret_value: 'second_world' }] + end + + subject { execute_service(variables_attributes: variables_attributes) } + + it 'creates a pipeline with specified variables' do + expect(subject.variables.map { |var| var.slice(:key, :secret_value) }) + .to eq variables_attributes.map(&:with_indifferent_access) + end + end end end diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb index 8a537e83d5f..8063bc7e1ac 100644 --- a/spec/services/ci/register_job_service_spec.rb +++ b/spec/services/ci/register_job_service_spec.rb @@ -2,11 +2,13 @@ require 'spec_helper' module Ci describe RegisterJobService do - let!(:project) { FactoryBot.create :project, shared_runners_enabled: false } - let!(:pipeline) { FactoryBot.create :ci_pipeline, project: project } - let!(:pending_job) { FactoryBot.create :ci_build, pipeline: pipeline } - let!(:shared_runner) { FactoryBot.create(:ci_runner, is_shared: true) } - let!(:specific_runner) { FactoryBot.create(:ci_runner, is_shared: false) } + set(:group) { create(:group) } + set(:project) { create(:project, group: group, shared_runners_enabled: false, group_runners_enabled: false) } + set(:pipeline) { create(:ci_pipeline, project: project) } + let!(:shared_runner) { create(:ci_runner, is_shared: true) } + let!(:specific_runner) { create(:ci_runner, is_shared: false) } + let!(:group_runner) { create(:ci_runner, groups: [group], runner_type: :group_type) } + let!(:pending_job) { create(:ci_build, pipeline: pipeline) } before do specific_runner.assign_to(project) @@ -150,7 +152,7 @@ module Ci context 'disallow when builds are disabled' do before do - project.update(shared_runners_enabled: true) + project.update(shared_runners_enabled: true, group_runners_enabled: true) project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED) end @@ -160,13 +162,90 @@ module Ci it { expect(build).to be_nil } end - context 'and uses specific runner' do + context 'and uses group runner' do + let(:build) { execute(group_runner) } + + it { expect(build).to be_nil } + end + + context 'and uses project runner' do let(:build) { execute(specific_runner) } it { expect(build).to be_nil } end end + context 'allow group runners' do + before do + project.update!(group_runners_enabled: true) + end + + context 'for multiple builds' do + let!(:project2) { create :project, group_runners_enabled: true, group: group } + let!(:pipeline2) { create :ci_pipeline, project: project2 } + let!(:project3) { create :project, group_runners_enabled: true, group: group } + let!(:pipeline3) { create :ci_pipeline, project: project3 } + + let!(:build1_project1) { pending_job } + let!(:build2_project1) { create :ci_build, pipeline: pipeline } + let!(:build3_project1) { create :ci_build, pipeline: pipeline } + let!(:build1_project2) { create :ci_build, pipeline: pipeline2 } + let!(:build2_project2) { create :ci_build, pipeline: pipeline2 } + let!(:build1_project3) { create :ci_build, pipeline: pipeline3 } + + # these shouldn't influence the scheduling + let!(:unrelated_group) { create :group } + let!(:unrelated_project) { create :project, group_runners_enabled: true, group: unrelated_group } + let!(:unrelated_pipeline) { create :ci_pipeline, project: unrelated_project } + let!(:build1_unrelated_project) { create :ci_build, pipeline: unrelated_pipeline } + let!(:unrelated_group_runner) { create :ci_runner, groups: [unrelated_group] } + + it 'does not consider builds from other group runners' do + expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 6 + execute(group_runner) + + expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 5 + execute(group_runner) + + expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 4 + execute(group_runner) + + expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 3 + execute(group_runner) + + expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 2 + execute(group_runner) + + expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 1 + execute(group_runner) + + expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 0 + expect(execute(group_runner)).to be_nil + end + end + + context 'group runner' do + let(:build) { execute(group_runner) } + + it { expect(build).to be_kind_of(Build) } + it { expect(build).to be_valid } + it { expect(build).to be_running } + it { expect(build.runner).to eq(group_runner) } + end + end + + context 'disallow group runners' do + before do + project.update!(group_runners_enabled: false) + end + + context 'group runner' do + let(:build) { execute(group_runner) } + + it { expect(build).to be_nil } + end + end + context 'when first build is stalled' do before do pending_job.update(lock_version: 0) @@ -178,7 +257,7 @@ module Ci let!(:other_build) { create :ci_build, pipeline: pipeline } before do - allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_specific_runner) + allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_project_runner) .and_return(Ci::Build.where(id: [pending_job, other_build])) end @@ -190,7 +269,7 @@ module Ci context 'when single build is in queue' do before do - allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_specific_runner) + allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_project_runner) .and_return(Ci::Build.where(id: pending_job)) end @@ -201,7 +280,7 @@ module Ci context 'when there is no build in queue' do before do - allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_specific_runner) + allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_project_runner) .and_return(Ci::Build.none) end diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb index 8de0bdf92e2..e1cb7ed8110 100644 --- a/spec/services/ci/retry_build_service_spec.rb +++ b/spec/services/ci/retry_build_service_spec.rb @@ -6,7 +6,9 @@ describe Ci::RetryBuildService do set(:pipeline) { create(:ci_pipeline, project: project) } let(:stage) do - Ci::Stage.create!(project: project, pipeline: pipeline, name: 'test') + create(:ci_stage_entity, project: project, + pipeline: pipeline, + name: 'test') end let(:build) { create(:ci_build, pipeline: pipeline, stage_id: stage.id) } @@ -30,7 +32,7 @@ describe Ci::RetryBuildService do runner_id tag_taggings taggings tags trigger_request_id user_id auto_canceled_by_id retried failure_reason artifacts_file_store artifacts_metadata_store - metadata].freeze + metadata trace_chunks].freeze shared_examples 'build duplication' do let(:another_pipeline) { create(:ci_empty_pipeline, project: project) } diff --git a/spec/services/ci/update_build_queue_service_spec.rb b/spec/services/ci/update_build_queue_service_spec.rb index 0da0e57dbcd..74a23ed2a3f 100644 --- a/spec/services/ci/update_build_queue_service_spec.rb +++ b/spec/services/ci/update_build_queue_service_spec.rb @@ -8,21 +8,19 @@ describe Ci::UpdateBuildQueueService do context 'when updating specific runners' do let(:runner) { create(:ci_runner) } - context 'when there are runner that can pick build' do + context 'when there is a runner that can pick build' do before do build.project.runners << runner end it 'ticks runner queue value' do - expect { subject.execute(build) } - .to change { runner.ensure_runner_queue_value } + expect { subject.execute(build) }.to change { runner.ensure_runner_queue_value } end end - context 'when there are no runners that can pick build' do + context 'when there is no runner that can pick build' do it 'does not tick runner queue value' do - expect { subject.execute(build) } - .not_to change { runner.ensure_runner_queue_value } + expect { subject.execute(build) }.not_to change { runner.ensure_runner_queue_value } end end end @@ -30,21 +28,61 @@ describe Ci::UpdateBuildQueueService do context 'when updating shared runners' do let(:runner) { create(:ci_runner, :shared) } - context 'when there are runner that can pick build' do + context 'when there is no runner that can pick build' do it 'ticks runner queue value' do - expect { subject.execute(build) } - .to change { runner.ensure_runner_queue_value } + expect { subject.execute(build) }.to change { runner.ensure_runner_queue_value } end end - context 'when there are no runners that can pick build' do + context 'when there is no runner that can pick build due to tag mismatch' do before do build.tag_list = [:docker] end it 'does not tick runner queue value' do - expect { subject.execute(build) } - .not_to change { runner.ensure_runner_queue_value } + expect { subject.execute(build) }.not_to change { runner.ensure_runner_queue_value } + end + end + + context 'when there is no runner that can pick build due to being disabled on project' do + before do + build.project.shared_runners_enabled = false + end + + it 'does not tick runner queue value' do + expect { subject.execute(build) }.not_to change { runner.ensure_runner_queue_value } + end + end + end + + context 'when updating group runners' do + let(:group) { create :group } + let(:project) { create :project, group: group } + let(:runner) { create :ci_runner, groups: [group] } + + context 'when there is a runner that can pick build' do + it 'ticks runner queue value' do + expect { subject.execute(build) }.to change { runner.ensure_runner_queue_value } + end + end + + context 'when there is no runner that can pick build due to tag mismatch' do + before do + build.tag_list = [:docker] + end + + it 'does not tick runner queue value' do + expect { subject.execute(build) }.not_to change { runner.ensure_runner_queue_value } + end + end + + context 'when there is no runner that can pick build due to being disabled on project' do + before do + build.project.group_runners_enabled = false + end + + it 'does not tick runner queue value' do + expect { subject.execute(build) }.not_to change { runner.ensure_runner_queue_value } end end end diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index 26fdf8d4b24..35826de5814 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -14,6 +14,72 @@ describe GitPushService, services: true do project.add_master(user) end + describe 'with remote mirrors' do + let(:project) { create(:project, :repository, :remote_mirror) } + + subject do + described_class.new(project, user, oldrev: oldrev, newrev: newrev, ref: ref) + end + + context 'when remote mirror feature is enabled' do + it 'fails stuck remote mirrors' do + allow(project).to receive(:update_remote_mirrors).and_return(project.remote_mirrors) + expect(project).to receive(:mark_stuck_remote_mirrors_as_failed!) + + subject.execute + end + + it 'updates remote mirrors' do + expect(project).to receive(:update_remote_mirrors) + + subject.execute + end + end + + context 'when remote mirror feature is disabled' do + before do + stub_application_setting(mirror_available: false) + end + + context 'with remote mirrors global setting overridden' do + before do + project.remote_mirror_available_overridden = true + end + + it 'fails stuck remote mirrors' do + allow(project).to receive(:update_remote_mirrors).and_return(project.remote_mirrors) + expect(project).to receive(:mark_stuck_remote_mirrors_as_failed!) + + subject.execute + end + + it 'updates remote mirrors' do + expect(project).to receive(:update_remote_mirrors) + + subject.execute + end + end + + context 'without remote mirrors global setting overridden' do + before do + project.remote_mirror_available_overridden = false + end + + it 'does not fails stuck remote mirrors' do + expect(project).not_to receive(:mark_stuck_remote_mirrors_as_failed!) + + subject.execute + end + + it 'does not updates remote mirrors' do + expect(project).not_to receive(:update_remote_mirrors) + + subject.execute + end + end + end + end + describe 'Push branches' do subject do execute_service(project, user, oldrev, newrev, ref) diff --git a/spec/services/issuable/common_system_notes_service_spec.rb b/spec/services/issuable/common_system_notes_service_spec.rb index b8fa3e3d124..dcf4503ef9c 100644 --- a/spec/services/issuable/common_system_notes_service_spec.rb +++ b/spec/services/issuable/common_system_notes_service_spec.rb @@ -5,34 +5,6 @@ describe Issuable::CommonSystemNotesService do let(:project) { create(:project) } let(:issuable) { create(:issue) } - shared_examples 'system note creation' do |update_params, note_text| - subject { described_class.new(project, user).execute(issuable, [])} - - before do - issuable.assign_attributes(update_params) - issuable.save - end - - it 'creates 1 system note with the correct content' do - expect { subject }.to change { Note.count }.from(0).to(1) - - note = Note.last - expect(note.note).to match(note_text) - expect(note.noteable_type).to eq(issuable.class.name) - end - end - - shared_examples 'WIP notes creation' do |wip_action| - subject { described_class.new(project, user).execute(issuable, []) } - - it 'creates WIP toggle and title change notes' do - expect { subject }.to change { Note.count }.from(0).to(2) - - expect(Note.first.note).to match("#{wip_action} as a **Work In Progress**") - expect(Note.second.note).to match('changed title') - end - end - describe '#execute' do it_behaves_like 'system note creation', { title: 'New title' }, 'changed title' it_behaves_like 'system note creation', { description: 'New description' }, 'changed the description' diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb index c38ddf4612b..e8568bf8bb3 100644 --- a/spec/services/merge_requests/merge_service_spec.rb +++ b/spec/services/merge_requests/merge_service_spec.rb @@ -219,7 +219,7 @@ describe MergeRequests::MergeService do service.execute(merge_request) - expect(merge_request.merge_error).to include(error_message) + expect(merge_request.merge_error).to include('Something went wrong during merge') expect(Rails.logger).to have_received(:error).with(a_string_matching(error_message)) end @@ -231,7 +231,7 @@ describe MergeRequests::MergeService do service.execute(merge_request) - expect(merge_request.merge_error).to include(error_message) + expect(merge_request.merge_error).to include('Something went wrong during merge pre-receive hook') expect(Rails.logger).to have_received(:error).with(a_string_matching(error_message)) end diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 48ef5f3c115..5f28bc123f3 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' describe NotificationService, :mailer do include EmailSpec::Matchers + include NotificationHelpers let(:notification) { described_class.new } let(:assignee) { create(:user) } @@ -13,12 +14,6 @@ describe NotificationService, :mailer do end shared_examples 'notifications for new mentions' do - def send_notifications(*new_mentions) - mentionable.description = new_mentions.map(&:to_reference).join(' ') - - notification.send(notification_method, mentionable, new_mentions, @u_disabled) - end - it 'sends no emails when no new mentions are present' do send_notifications should_not_email_anyone @@ -1914,30 +1909,6 @@ describe NotificationService, :mailer do group end - def create_global_setting_for(user, level) - setting = user.global_notification_setting - setting.level = level - setting.save - - user - end - - def create_user_with_notification(level, username, resource = project) - user = create(:user, username: username) - setting = user.notification_settings_for(resource) - setting.level = level - setting.save - - user - end - - # Create custom notifications - # When resource is nil it means global notification - def update_custom_notification(event, user, resource: nil, value: true) - setting = user.notification_settings_for(resource) - setting.update!(event => value) - end - def add_users_with_subscription(project, issuable) @subscriber = create :user @unsubscriber = create :user diff --git a/spec/services/projects/create_from_template_service_spec.rb b/spec/services/projects/create_from_template_service_spec.rb index d40e6f1449d..9aa9237d875 100644 --- a/spec/services/projects/create_from_template_service_spec.rb +++ b/spec/services/projects/create_from_template_service_spec.rb @@ -23,7 +23,7 @@ describe Projects::CreateFromTemplateService do project = subject.execute expect(project).to be_saved - expect(project.scheduled?).to be(true) + expect(project.import_scheduled?).to be(true) end context 'the result project' do diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb index b2c52214f48..b63f409579e 100644 --- a/spec/services/projects/destroy_service_spec.rb +++ b/spec/services/projects/destroy_service_spec.rb @@ -65,6 +65,19 @@ describe Projects::DestroyService do Sidekiq::Testing.inline! { destroy_project(project, user, {}) } end + context 'when has remote mirrors' do + let!(:project) do + create(:project, :repository, namespace: user.namespace).tap do |project| + project.remote_mirrors.create(url: 'http://test.com') + end + end + let!(:async) { true } + + it 'destroys them' do + expect(RemoteMirror.count).to eq(0) + end + end + it_behaves_like 'deleting the project' it 'invalidates personal_project_count cache' do diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb index a418808fd26..347ac13828c 100644 --- a/spec/services/projects/update_pages_service_spec.rb +++ b/spec/services/projects/update_pages_service_spec.rb @@ -123,11 +123,13 @@ describe Projects::UpdatePagesService do expect(execute).not_to eq(:success) end - it 'fails for empty file fails' do - build.job_artifacts_archive.update_attributes(file: empty_file) + context 'when using empty file' do + let(:file) { empty_file } - expect { execute } - .to raise_error(Projects::UpdatePagesService::FailedToExtractError) + it 'fails to extract' do + expect { execute } + .to raise_error(Projects::UpdatePagesService::FailedToExtractError) + end end context 'when timeout happens by DNS error' do diff --git a/spec/services/projects/update_remote_mirror_service_spec.rb b/spec/services/projects/update_remote_mirror_service_spec.rb new file mode 100644 index 00000000000..be09afd9f36 --- /dev/null +++ b/spec/services/projects/update_remote_mirror_service_spec.rb @@ -0,0 +1,355 @@ +require 'spec_helper' + +describe Projects::UpdateRemoteMirrorService do + let(:project) { create(:project, :repository) } + let(:remote_project) { create(:forked_project_with_submodules) } + let(:repository) { project.repository } + let(:raw_repository) { repository.raw } + let(:remote_mirror) { project.remote_mirrors.create!(url: remote_project.http_url_to_repo, enabled: true, only_protected_branches: false) } + + subject { described_class.new(project, project.creator) } + + describe "#execute", :skip_gitaly_mock do + before do + create_branch(repository, 'existing-branch') + allow(raw_repository).to receive(:remote_tags) do + generate_tags(repository, 'v1.0.0', 'v1.1.0') + end + allow(raw_repository).to receive(:push_remote_branches).and_return(true) + end + + it "fetches the remote repository" do + expect(repository).to receive(:fetch_remote).with(remote_mirror.remote_name, no_tags: true) do + sync_remote(repository, remote_mirror.remote_name, local_branch_names) + end + + subject.execute(remote_mirror) + end + + it "succeeds" do + allow(repository).to receive(:fetch_remote) { sync_remote(repository, remote_mirror.remote_name, local_branch_names) } + + result = subject.execute(remote_mirror) + + expect(result[:status]).to eq(:success) + end + + describe 'Syncing branches' do + it "push all the branches the first time" do + allow(repository).to receive(:fetch_remote) + + expect(raw_repository).to receive(:push_remote_branches).with(remote_mirror.remote_name, local_branch_names) + + subject.execute(remote_mirror) + end + + it "does not push anything is remote is up to date" do + allow(repository).to receive(:fetch_remote) { sync_remote(repository, remote_mirror.remote_name, local_branch_names) } + + expect(raw_repository).not_to receive(:push_remote_branches) + + subject.execute(remote_mirror) + end + + it "sync new branches" do + # call local_branch_names early so it is not called after the new branch has been created + current_branches = local_branch_names + allow(repository).to receive(:fetch_remote) { sync_remote(repository, remote_mirror.remote_name, current_branches) } + create_branch(repository, 'my-new-branch') + + expect(raw_repository).to receive(:push_remote_branches).with(remote_mirror.remote_name, ['my-new-branch']) + + subject.execute(remote_mirror) + end + + it "sync updated branches" do + allow(repository).to receive(:fetch_remote) do + sync_remote(repository, remote_mirror.remote_name, local_branch_names) + update_branch(repository, 'existing-branch') + end + + expect(raw_repository).to receive(:push_remote_branches).with(remote_mirror.remote_name, ['existing-branch']) + + subject.execute(remote_mirror) + end + + context 'when push only protected branches option is set' do + let(:unprotected_branch_name) { 'existing-branch' } + let(:protected_branch_name) do + project.repository.branch_names.find { |n| n != unprotected_branch_name } + end + let!(:protected_branch) do + create(:protected_branch, project: project, name: protected_branch_name) + end + + before do + project.reload + remote_mirror.only_protected_branches = true + end + + it "sync updated protected branches" do + allow(repository).to receive(:fetch_remote) do + sync_remote(repository, remote_mirror.remote_name, local_branch_names) + update_branch(repository, protected_branch_name) + end + + expect(raw_repository).to receive(:push_remote_branches).with(remote_mirror.remote_name, [protected_branch_name]) + + subject.execute(remote_mirror) + end + + it 'does not sync unprotected branches' do + allow(repository).to receive(:fetch_remote) do + sync_remote(repository, remote_mirror.remote_name, local_branch_names) + update_branch(repository, unprotected_branch_name) + end + + expect(raw_repository).not_to receive(:push_remote_branches).with(remote_mirror.remote_name, [unprotected_branch_name]) + + subject.execute(remote_mirror) + end + end + + context 'when branch exists in local and remote repo' do + context 'when it has diverged' do + it 'syncs branches' do + allow(repository).to receive(:fetch_remote) do + sync_remote(repository, remote_mirror.remote_name, local_branch_names) + update_remote_branch(repository, remote_mirror.remote_name, 'markdown') + end + + expect(raw_repository).to receive(:push_remote_branches).with(remote_mirror.remote_name, ['markdown']) + + subject.execute(remote_mirror) + end + end + end + + describe 'for delete' do + context 'when branch exists in local and remote repo' do + it 'deletes the branch from remote repo' do + allow(repository).to receive(:fetch_remote) do + sync_remote(repository, remote_mirror.remote_name, local_branch_names) + delete_branch(repository, 'existing-branch') + end + + expect(raw_repository).to receive(:delete_remote_branches).with(remote_mirror.remote_name, ['existing-branch']) + + subject.execute(remote_mirror) + end + end + + context 'when push only protected branches option is set' do + before do + remote_mirror.only_protected_branches = true + end + + context 'when branch exists in local and remote repo' do + let!(:protected_branch_name) { local_branch_names.first } + + before do + create(:protected_branch, project: project, name: protected_branch_name) + project.reload + end + + it 'deletes the protected branch from remote repo' do + allow(repository).to receive(:fetch_remote) do + sync_remote(repository, remote_mirror.remote_name, local_branch_names) + delete_branch(repository, protected_branch_name) + end + + expect(raw_repository).not_to receive(:delete_remote_branches).with(remote_mirror.remote_name, [protected_branch_name]) + + subject.execute(remote_mirror) + end + + it 'does not delete the unprotected branch from remote repo' do + allow(repository).to receive(:fetch_remote) do + sync_remote(repository, remote_mirror.remote_name, local_branch_names) + delete_branch(repository, 'existing-branch') + end + + expect(raw_repository).not_to receive(:delete_remote_branches).with(remote_mirror.remote_name, ['existing-branch']) + + subject.execute(remote_mirror) + end + end + + context 'when branch only exists on remote repo' do + let!(:protected_branch_name) { 'remote-branch' } + + before do + create(:protected_branch, project: project, name: protected_branch_name) + end + + context 'when it has diverged' do + it 'does not delete the remote branch' do + allow(repository).to receive(:fetch_remote) do + sync_remote(repository, remote_mirror.remote_name, local_branch_names) + + rev = repository.find_branch('markdown').dereferenced_target + create_remote_branch(repository, remote_mirror.remote_name, 'remote-branch', rev.id) + end + + expect(raw_repository).not_to receive(:delete_remote_branches) + + subject.execute(remote_mirror) + end + end + + context 'when it has not diverged' do + it 'deletes the remote branch' do + allow(repository).to receive(:fetch_remote) do + sync_remote(repository, remote_mirror.remote_name, local_branch_names) + + masterrev = repository.find_branch('master').dereferenced_target + create_remote_branch(repository, remote_mirror.remote_name, protected_branch_name, masterrev.id) + end + + expect(raw_repository).to receive(:delete_remote_branches).with(remote_mirror.remote_name, [protected_branch_name]) + + subject.execute(remote_mirror) + end + end + end + end + + context 'when branch only exists on remote repo' do + context 'when it has diverged' do + it 'does not delete the remote branch' do + allow(repository).to receive(:fetch_remote) do + sync_remote(repository, remote_mirror.remote_name, local_branch_names) + + rev = repository.find_branch('markdown').dereferenced_target + create_remote_branch(repository, remote_mirror.remote_name, 'remote-branch', rev.id) + end + + expect(raw_repository).not_to receive(:delete_remote_branches) + + subject.execute(remote_mirror) + end + end + + context 'when it has not diverged' do + it 'deletes the remote branch' do + allow(repository).to receive(:fetch_remote) do + sync_remote(repository, remote_mirror.remote_name, local_branch_names) + + masterrev = repository.find_branch('master').dereferenced_target + create_remote_branch(repository, remote_mirror.remote_name, 'remote-branch', masterrev.id) + end + + expect(raw_repository).to receive(:delete_remote_branches).with(remote_mirror.remote_name, ['remote-branch']) + + subject.execute(remote_mirror) + end + end + end + end + end + + describe 'Syncing tags' do + before do + allow(repository).to receive(:fetch_remote) { sync_remote(repository, remote_mirror.remote_name, local_branch_names) } + end + + context 'when there are not tags to push' do + it 'does not try to push tags' do + allow(repository).to receive(:remote_tags) { {} } + allow(repository).to receive(:tags) { [] } + + expect(repository).not_to receive(:push_tags) + + subject.execute(remote_mirror) + end + end + + context 'when there are some tags to push' do + it 'pushes tags to remote' do + allow(raw_repository).to receive(:remote_tags) { {} } + + expect(raw_repository).to receive(:push_remote_branches).with(remote_mirror.remote_name, ['v1.0.0', 'v1.1.0']) + + subject.execute(remote_mirror) + end + end + + context 'when there are some tags to delete' do + it 'deletes tags from remote' do + remote_tags = generate_tags(repository, 'v1.0.0', 'v1.1.0') + allow(raw_repository).to receive(:remote_tags) { remote_tags } + + repository.rm_tag(create(:user), 'v1.0.0') + + expect(raw_repository).to receive(:delete_remote_branches).with(remote_mirror.remote_name, ['v1.0.0']) + + subject.execute(remote_mirror) + end + end + end + end + + def create_branch(repository, branch_name) + rugged = repository.rugged + masterrev = repository.find_branch('master').dereferenced_target + parentrev = repository.commit(masterrev).parent_id + + rugged.references.create("refs/heads/#{branch_name}", parentrev) + + repository.expire_branches_cache + end + + def create_remote_branch(repository, remote_name, branch_name, source_id) + rugged = repository.rugged + + rugged.references.create("refs/remotes/#{remote_name}/#{branch_name}", source_id) + end + + def sync_remote(repository, remote_name, local_branch_names) + rugged = repository.rugged + + local_branch_names.each do |branch| + target = repository.find_branch(branch).try(:dereferenced_target) + rugged.references.create("refs/remotes/#{remote_name}/#{branch}", target.id) if target + end + end + + def update_remote_branch(repository, remote_name, branch) + rugged = repository.rugged + masterrev = repository.find_branch('master').dereferenced_target.id + + rugged.references.create("refs/remotes/#{remote_name}/#{branch}", masterrev, force: true) + repository.expire_branches_cache + end + + def update_branch(repository, branch) + rugged = repository.rugged + masterrev = repository.find_branch('master').dereferenced_target.id + + # Updated existing branch + rugged.references.create("refs/heads/#{branch}", masterrev, force: true) + repository.expire_branches_cache + end + + def delete_branch(repository, branch) + rugged = repository.rugged + + rugged.references.delete("refs/heads/#{branch}") + repository.expire_branches_cache + end + + def generate_tags(repository, *tag_names) + tag_names.each_with_object([]) do |name, tags| + tag = repository.find_tag(name) + target = tag.try(:target) + target_commit = tag.try(:dereferenced_target) + tags << Gitlab::Git::Tag.new(repository.raw_repository, name, target, target_commit) + end + end + + def local_branch_names + branch_names = repository.branches.map(&:name) + # we want the protected branch to be pushed first + branch_names.unshift(branch_names.delete('master')) + end +end diff --git a/spec/services/test_hooks/project_service_spec.rb b/spec/services/test_hooks/project_service_spec.rb index 28dfa9cf59c..962b9f40c4f 100644 --- a/spec/services/test_hooks/project_service_spec.rb +++ b/spec/services/test_hooks/project_service_spec.rb @@ -170,6 +170,7 @@ describe TestHooks::ProjectService do end context 'wiki_page_events' do + let(:project) { create(:project, :wiki_repo) } let(:trigger) { 'wiki_page_events' } let(:trigger_key) { :wiki_page_hooks } diff --git a/spec/services/users/respond_to_terms_service_spec.rb b/spec/services/users/respond_to_terms_service_spec.rb new file mode 100644 index 00000000000..fb08dd10b87 --- /dev/null +++ b/spec/services/users/respond_to_terms_service_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe Users::RespondToTermsService do + let(:user) { create(:user) } + let(:term) { create(:term) } + + subject(:service) { described_class.new(user, term) } + + describe '#execute' do + it 'creates a new agreement if it did not exist' do + expect { service.execute(accepted: true) } + .to change { user.term_agreements.size }.by(1) + end + + it 'updates an agreement if it existed' do + agreement = create(:term_agreement, user: user, term: term, accepted: true) + + service.execute(accepted: true) + + expect(agreement.reload.accepted).to be_truthy + end + + it 'adds the accepted terms to the user' do + service.execute(accepted: true) + + expect(user.reload.accepted_term).to eq(term) + end + + it 'removes accepted terms when declining' do + user.update!(accepted_term: term) + + service.execute(accepted: false) + + expect(user.reload.accepted_term).to be_nil + end + end +end diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb index 2ef2e61babc..7995f2c9ae7 100644 --- a/spec/services/web_hook_service_spec.rb +++ b/spec/services/web_hook_service_spec.rb @@ -67,7 +67,7 @@ describe WebHookService do end it 'handles exceptions' do - exceptions = [SocketError, OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout, Net::ReadTimeout] + exceptions = [SocketError, OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout, Net::ReadTimeout, Gitlab::HTTP::BlockedUrlError] exceptions.each do |exception_class| exception = exception_class.new('Exception message') diff --git a/spec/services/wiki_pages/create_service_spec.rb b/spec/services/wiki_pages/create_service_spec.rb index b270194d9b8..259f445247e 100644 --- a/spec/services/wiki_pages/create_service_spec.rb +++ b/spec/services/wiki_pages/create_service_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe WikiPages::CreateService do - let(:project) { create(:project) } + let(:project) { create(:project, :wiki_repo) } let(:user) { create(:user) } let(:opts) do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index cc61cd7d838..b4fc596a751 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -86,6 +86,7 @@ RSpec.configure do |config| config.include WaitForRequests, :js config.include LiveDebugger, :js config.include MigrationsHelpers, :migration + config.include RedisHelpers if ENV['CI'] # This includes the first try, i.e. tests will be run 4 times before failing. @@ -146,21 +147,27 @@ RSpec.configure do |config| end config.around(:each, :clean_gitlab_redis_cache) do |example| - Gitlab::Redis::Cache.with(&:flushall) + redis_cache_cleanup! example.run - Gitlab::Redis::Cache.with(&:flushall) + redis_cache_cleanup! end config.around(:each, :clean_gitlab_redis_shared_state) do |example| - Gitlab::Redis::SharedState.with(&:flushall) - Sidekiq.redis(&:flushall) + redis_shared_state_cleanup! example.run - Gitlab::Redis::SharedState.with(&:flushall) - Sidekiq.redis(&:flushall) + redis_shared_state_cleanup! + end + + config.around(:each, :clean_gitlab_redis_queues) do |example| + redis_queues_cleanup! + + example.run + + redis_queues_cleanup! end # The :each scope runs "inside" the example, so this hook ensures the DB is in the diff --git a/spec/support/chunked_io/chunked_io_helpers.rb b/spec/support/chunked_io/chunked_io_helpers.rb new file mode 100644 index 00000000000..fec1f951563 --- /dev/null +++ b/spec/support/chunked_io/chunked_io_helpers.rb @@ -0,0 +1,11 @@ +module ChunkedIOHelpers + def sample_trace_raw + @sample_trace_raw ||= File.read(expand_fixture_path('trace/sample_trace')) + .force_encoding(Encoding::BINARY) + end + + def stub_buffer_size(size) + stub_const('Ci::BuildTraceChunk::CHUNK_SIZE', size) + stub_const('Gitlab::Ci::Trace::ChunkedIO::CHUNK_SIZE', size) + end +end diff --git a/spec/support/helpers/notification_helpers.rb b/spec/support/helpers/notification_helpers.rb new file mode 100644 index 00000000000..8d84510fb73 --- /dev/null +++ b/spec/support/helpers/notification_helpers.rb @@ -0,0 +1,33 @@ +module NotificationHelpers + extend self + + def send_notifications(*new_mentions) + mentionable.description = new_mentions.map(&:to_reference).join(' ') + + notification.send(notification_method, mentionable, new_mentions, @u_disabled) + end + + def create_global_setting_for(user, level) + setting = user.global_notification_setting + setting.level = level + setting.save + + user + end + + def create_user_with_notification(level, username, resource = project) + user = create(:user, username: username) + setting = user.notification_settings_for(resource) + setting.level = level + setting.save + + user + end + + # Create custom notifications + # When resource is nil it means global notification + def update_custom_notification(event, user, resource: nil, value: true) + setting = user.notification_settings_for(resource) + setting.update!(event => value) + end +end diff --git a/spec/support/helpers/terms_helper.rb b/spec/support/helpers/terms_helper.rb new file mode 100644 index 00000000000..a00ec14138b --- /dev/null +++ b/spec/support/helpers/terms_helper.rb @@ -0,0 +1,19 @@ +module TermsHelper + def enforce_terms + stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') + settings = Gitlab::CurrentSettings.current_application_settings + ApplicationSettings::UpdateService.new( + settings, nil, terms: 'These are the terms', enforce_terms: true + ).execute + end + + def accept_terms(user) + terms = Gitlab::CurrentSettings.current_application_settings.latest_terms + Users::RespondToTermsService.new(user, terms).execute(accepted: true) + end + + def expect_to_be_on_terms_page + expect(current_path).to eq terms_path + expect(page).to have_content('Please accept the Terms of Service before continuing.') + end +end diff --git a/spec/support/redis/redis_helpers.rb b/spec/support/redis/redis_helpers.rb new file mode 100644 index 00000000000..0457e8487d8 --- /dev/null +++ b/spec/support/redis/redis_helpers.rb @@ -0,0 +1,18 @@ +module RedisHelpers + # config/README.md + + # Usage: performance enhancement + def redis_cache_cleanup! + Gitlab::Redis::Cache.with(&:flushall) + end + + # Usage: SideKiq, Mailroom, CI Runner, Workhorse, push services + def redis_queues_cleanup! + Gitlab::Redis::Queues.with(&:flushall) + end + + # Usage: session state, rate limiting + def redis_shared_state_cleanup! + Gitlab::Redis::SharedState.with(&:flushall) + end +end diff --git a/spec/lib/gitlab/email/email_shared_blocks.rb b/spec/support/shared_contexts/email_shared_blocks.rb index 9d806fc524d..9d806fc524d 100644 --- a/spec/lib/gitlab/email/email_shared_blocks.rb +++ b/spec/support/shared_contexts/email_shared_blocks.rb diff --git a/spec/support/shared_examples/ci_trace_shared_examples.rb b/spec/support/shared_examples/ci_trace_shared_examples.rb new file mode 100644 index 00000000000..21c6f3c829f --- /dev/null +++ b/spec/support/shared_examples/ci_trace_shared_examples.rb @@ -0,0 +1,741 @@ +shared_examples_for 'common trace features' do + describe '#html' do + before do + trace.set("12\n34") + end + + it "returns formatted html" do + expect(trace.html).to eq("12<br>34") + end + + it "returns last line of formatted html" do + expect(trace.html(last_lines: 1)).to eq("34") + end + end + + describe '#raw' do + before do + trace.set("12\n34") + end + + it "returns raw output" do + expect(trace.raw).to eq("12\n34") + end + + it "returns last line of raw output" do + expect(trace.raw(last_lines: 1)).to eq("34") + end + end + + describe '#extract_coverage' do + let(:regex) { '\(\d+.\d+\%\) covered' } + + context 'matching coverage' do + before do + trace.set('Coverage 1033 / 1051 LOC (98.29%) covered') + end + + it "returns valid coverage" do + expect(trace.extract_coverage(regex)).to eq("98.29") + end + end + + context 'no coverage' do + before do + trace.set('No coverage') + end + + it 'returs nil' do + expect(trace.extract_coverage(regex)).to be_nil + end + end + end + + describe '#extract_sections' do + let(:log) { 'No sections' } + let(:sections) { trace.extract_sections } + + before do + trace.set(log) + end + + context 'no sections' do + it 'returs []' do + expect(trace.extract_sections).to eq([]) + end + end + + context 'multiple sections available' do + let(:log) { File.read(expand_fixture_path('trace/trace_with_sections')) } + let(:sections_data) do + [ + { name: 'prepare_script', lines: 2, duration: 3.seconds }, + { name: 'get_sources', lines: 4, duration: 1.second }, + { name: 'restore_cache', lines: 0, duration: 0.seconds }, + { name: 'download_artifacts', lines: 0, duration: 0.seconds }, + { name: 'build_script', lines: 2, duration: 1.second }, + { name: 'after_script', lines: 0, duration: 0.seconds }, + { name: 'archive_cache', lines: 0, duration: 0.seconds }, + { name: 'upload_artifacts', lines: 0, duration: 0.seconds } + ] + end + + it "returns valid sections" do + expect(sections).not_to be_empty + expect(sections.size).to eq(sections_data.size), + "expected #{sections_data.size} sections, got #{sections.size}" + + buff = StringIO.new(log) + sections.each_with_index do |s, i| + expected = sections_data[i] + + expect(s[:name]).to eq(expected[:name]) + expect(s[:date_end] - s[:date_start]).to eq(expected[:duration]) + + buff.seek(s[:byte_start], IO::SEEK_SET) + length = s[:byte_end] - s[:byte_start] + lines = buff.read(length).count("\n") + expect(lines).to eq(expected[:lines]) + end + end + end + + context 'logs contains "section_start"' do + let(:log) { "section_start:1506417476:a_section\r\033[0Klooks like a section_start:invalid\nsection_end:1506417477:a_section\r\033[0K"} + + it "returns only one section" do + expect(sections).not_to be_empty + expect(sections.size).to eq(1) + + section = sections[0] + expect(section[:name]).to eq('a_section') + expect(section[:byte_start]).not_to eq(section[:byte_end]), "got an empty section" + end + end + + context 'missing section_end' do + let(:log) { "section_start:1506417476:a_section\r\033[0KSome logs\nNo section_end\n"} + + it "returns no sections" do + expect(sections).to be_empty + end + end + + context 'missing section_start' do + let(:log) { "Some logs\nNo section_start\nsection_end:1506417476:a_section\r\033[0K"} + + it "returns no sections" do + expect(sections).to be_empty + end + end + + context 'inverted section_start section_end' do + let(:log) { "section_end:1506417476:a_section\r\033[0Klooks like a section_start:invalid\nsection_start:1506417477:a_section\r\033[0K"} + + it "returns no sections" do + expect(sections).to be_empty + end + end + end + + describe '#set' do + before do + trace.set("12") + end + + it "returns trace" do + expect(trace.raw).to eq("12") + end + + context 'overwrite trace' do + before do + trace.set("34") + end + + it "returns new trace" do + expect(trace.raw).to eq("34") + end + end + + context 'runners token' do + let(:token) { 'my_secret_token' } + + before do + build.project.update(runners_token: token) + trace.set(token) + end + + it "hides token" do + expect(trace.raw).not_to include(token) + end + end + + context 'hides build token' do + let(:token) { 'my_secret_token' } + + before do + build.update(token: token) + trace.set(token) + end + + it "hides token" do + expect(trace.raw).not_to include(token) + end + end + end + + describe '#append' do + before do + trace.set("1234") + end + + it "returns correct trace" do + expect(trace.append("56", 4)).to eq(6) + expect(trace.raw).to eq("123456") + end + + context 'tries to append trace at different offset' do + it "fails with append" do + expect(trace.append("56", 2)).to eq(4) + expect(trace.raw).to eq("1234") + end + end + + context 'runners token' do + let(:token) { 'my_secret_token' } + + before do + build.project.update(runners_token: token) + trace.append(token, 0) + end + + it "hides token" do + expect(trace.raw).not_to include(token) + end + end + + context 'build token' do + let(:token) { 'my_secret_token' } + + before do + build.update(token: token) + trace.append(token, 0) + end + + it "hides token" do + expect(trace.raw).not_to include(token) + end + end + end +end + +shared_examples_for 'trace with disabled live trace feature' do + it_behaves_like 'common trace features' + + describe '#read' do + shared_examples 'read successfully with IO' do + it 'yields with source' do + trace.read do |stream| + expect(stream).to be_a(Gitlab::Ci::Trace::Stream) + expect(stream.stream).to be_a(IO) + end + end + end + + shared_examples 'read successfully with StringIO' do + it 'yields with source' do + trace.read do |stream| + expect(stream).to be_a(Gitlab::Ci::Trace::Stream) + expect(stream.stream).to be_a(StringIO) + end + end + end + + shared_examples 'failed to read' do + it 'yields without source' do + trace.read do |stream| + expect(stream).to be_a(Gitlab::Ci::Trace::Stream) + expect(stream.stream).to be_nil + end + end + end + + context 'when trace artifact exists' do + before do + create(:ci_job_artifact, :trace, job: build) + end + + it_behaves_like 'read successfully with IO' + end + + context 'when current_path (with project_id) exists' do + before do + expect(trace).to receive(:default_path) { expand_fixture_path('trace/sample_trace') } + end + + it_behaves_like 'read successfully with IO' + end + + context 'when current_path (with project_ci_id) exists' do + before do + expect(trace).to receive(:deprecated_path) { expand_fixture_path('trace/sample_trace') } + end + + it_behaves_like 'read successfully with IO' + end + + context 'when db trace exists' do + before do + build.send(:write_attribute, :trace, "data") + end + + it_behaves_like 'read successfully with StringIO' + end + + context 'when no sources exist' do + it_behaves_like 'failed to read' + end + end + + describe 'trace handling' do + subject { trace.exist? } + + context 'trace does not exist' do + it { expect(trace.exist?).to be(false) } + end + + context 'when trace artifact exists' do + before do + create(:ci_job_artifact, :trace, job: build) + end + + it { is_expected.to be_truthy } + + context 'when the trace artifact has been erased' do + before do + trace.erase! + end + + it { is_expected.to be_falsy } + + it 'removes associations' do + expect(Ci::JobArtifact.exists?(job_id: build.id, file_type: :trace)).to be_falsy + end + end + end + + context 'new trace path is used' do + before do + trace.send(:ensure_directory) + + File.open(trace.send(:default_path), "w") do |file| + file.write("data") + end + end + + it "trace exist" do + expect(trace.exist?).to be(true) + end + + it "can be erased" do + trace.erase! + expect(trace.exist?).to be(false) + end + end + + context 'deprecated path' do + let(:path) { trace.send(:deprecated_path) } + + context 'with valid ci_id' do + before do + build.project.update(ci_id: 1000) + + FileUtils.mkdir_p(File.dirname(path)) + + File.open(path, "w") do |file| + file.write("data") + end + end + + it "trace exist" do + expect(trace.exist?).to be(true) + end + + it "can be erased" do + trace.erase! + expect(trace.exist?).to be(false) + end + end + + context 'without valid ci_id' do + it "does not return deprecated path" do + expect(path).to be_nil + end + end + end + + context 'stored in database' do + before do + build.send(:write_attribute, :trace, "data") + end + + it "trace exist" do + expect(trace.exist?).to be(true) + end + + it "can be erased" do + trace.erase! + expect(trace.exist?).to be(false) + end + + it "returns database data" do + expect(trace.raw).to eq("data") + end + end + end + + describe '#archive!' do + subject { trace.archive! } + + shared_examples 'archive trace file' do + it do + expect { subject }.to change { Ci::JobArtifact.count }.by(1) + + build.reload + expect(build.trace.exist?).to be_truthy + expect(build.job_artifacts_trace.file.exists?).to be_truthy + expect(build.job_artifacts_trace.file.filename).to eq('job.log') + expect(File.exist?(src_path)).to be_falsy + expect(src_checksum) + .to eq(Digest::SHA256.file(build.job_artifacts_trace.file.path).hexdigest) + expect(build.job_artifacts_trace.file_sha256).to eq(src_checksum) + end + end + + shared_examples 'source trace file stays intact' do |error:| + it do + expect { subject }.to raise_error(error) + + build.reload + expect(build.trace.exist?).to be_truthy + expect(build.job_artifacts_trace).to be_nil + expect(File.exist?(src_path)).to be_truthy + end + end + + shared_examples 'archive trace in database' do + it do + expect { subject }.to change { Ci::JobArtifact.count }.by(1) + + build.reload + expect(build.trace.exist?).to be_truthy + expect(build.job_artifacts_trace.file.exists?).to be_truthy + expect(build.job_artifacts_trace.file.filename).to eq('job.log') + expect(build.old_trace).to be_nil + expect(src_checksum) + .to eq(Digest::SHA256.file(build.job_artifacts_trace.file.path).hexdigest) + expect(build.job_artifacts_trace.file_sha256).to eq(src_checksum) + end + end + + shared_examples 'source trace in database stays intact' do |error:| + it do + expect { subject }.to raise_error(error) + + build.reload + expect(build.trace.exist?).to be_truthy + expect(build.job_artifacts_trace).to be_nil + expect(build.old_trace).to eq(trace_content) + end + end + + context 'when job does not have trace artifact' do + context 'when trace file stored in default path' do + let!(:build) { create(:ci_build, :success, :trace_live) } + let!(:src_path) { trace.read { |s| s.path } } + let!(:src_checksum) { Digest::SHA256.file(src_path).hexdigest } + + it_behaves_like 'archive trace file' + + context 'when failed to create clone file' do + before do + allow(IO).to receive(:copy_stream).and_return(0) + end + + it_behaves_like 'source trace file stays intact', error: Gitlab::Ci::Trace::ArchiveError + end + + context 'when failed to create job artifact record' do + before do + allow_any_instance_of(Ci::JobArtifact).to receive(:save).and_return(false) + allow_any_instance_of(Ci::JobArtifact).to receive_message_chain(:errors, :full_messages) + .and_return(%w[Error Error]) + end + + it_behaves_like 'source trace file stays intact', error: ActiveRecord::RecordInvalid + end + end + + context 'when trace is stored in database' do + let(:build) { create(:ci_build, :success) } + let(:trace_content) { 'Sample trace' } + let(:src_checksum) { Digest::SHA256.hexdigest(trace_content) } + + before do + build.update_column(:trace, trace_content) + end + + it_behaves_like 'archive trace in database' + + context 'when failed to create clone file' do + before do + allow(IO).to receive(:copy_stream).and_return(0) + end + + it_behaves_like 'source trace in database stays intact', error: Gitlab::Ci::Trace::ArchiveError + end + + context 'when failed to create job artifact record' do + before do + allow_any_instance_of(Ci::JobArtifact).to receive(:save).and_return(false) + allow_any_instance_of(Ci::JobArtifact).to receive_message_chain(:errors, :full_messages) + .and_return(%w[Error Error]) + end + + it_behaves_like 'source trace in database stays intact', error: ActiveRecord::RecordInvalid + end + + context 'when there is a validation error on Ci::Build' do + before do + allow_any_instance_of(Ci::Build).to receive(:save).and_return(false) + allow_any_instance_of(Ci::Build).to receive_message_chain(:errors, :full_messages) + .and_return(%w[Error Error]) + end + + context "when erase old trace with 'save'" do + before do + build.send(:write_attribute, :trace, nil) + build.save + end + + it 'old trace is not deleted' do + build.reload + expect(build.trace.raw).to eq(trace_content) + end + end + + it_behaves_like 'archive trace in database' + end + end + end + + context 'when job has trace artifact' do + before do + create(:ci_job_artifact, :trace, job: build) + end + + it 'does not archive' do + expect_any_instance_of(described_class).not_to receive(:archive_stream!) + expect { subject }.to raise_error('Already archived') + expect(build.job_artifacts_trace.file.exists?).to be_truthy + end + end + + context 'when job is not finished yet' do + let!(:build) { create(:ci_build, :running, :trace_live) } + + it 'does not archive' do + expect_any_instance_of(described_class).not_to receive(:archive_stream!) + expect { subject }.to raise_error('Job is not finished yet') + expect(build.trace.exist?).to be_truthy + end + end + end +end + +shared_examples_for 'trace with enabled live trace feature' do + it_behaves_like 'common trace features' + + describe '#read' do + shared_examples 'read successfully with IO' do + it 'yields with source' do + trace.read do |stream| + expect(stream).to be_a(Gitlab::Ci::Trace::Stream) + expect(stream.stream).to be_a(IO) + end + end + end + + shared_examples 'read successfully with ChunkedIO' do + it 'yields with source' do + trace.read do |stream| + expect(stream).to be_a(Gitlab::Ci::Trace::Stream) + expect(stream.stream).to be_a(Gitlab::Ci::Trace::ChunkedIO) + end + end + end + + shared_examples 'failed to read' do + it 'yields without source' do + trace.read do |stream| + expect(stream).to be_a(Gitlab::Ci::Trace::Stream) + expect(stream.stream).to be_nil + end + end + end + + context 'when trace artifact exists' do + before do + create(:ci_job_artifact, :trace, job: build) + end + + it_behaves_like 'read successfully with IO' + end + + context 'when live trace exists' do + before do + Gitlab::Ci::Trace::ChunkedIO.new(build) do |stream| + stream.write('abc') + end + end + + it_behaves_like 'read successfully with ChunkedIO' + end + + context 'when no sources exist' do + it_behaves_like 'failed to read' + end + end + + describe 'trace handling' do + subject { trace.exist? } + + context 'trace does not exist' do + it { expect(trace.exist?).to be(false) } + end + + context 'when trace artifact exists' do + before do + create(:ci_job_artifact, :trace, job: build) + end + + it { is_expected.to be_truthy } + + context 'when the trace artifact has been erased' do + before do + trace.erase! + end + + it { is_expected.to be_falsy } + + it 'removes associations' do + expect(Ci::JobArtifact.exists?(job_id: build.id, file_type: :trace)).to be_falsy + end + end + end + + context 'stored in live trace' do + before do + Gitlab::Ci::Trace::ChunkedIO.new(build) do |stream| + stream.write('abc') + end + end + + it "trace exist" do + expect(trace.exist?).to be(true) + end + + it "can be erased" do + trace.erase! + expect(trace.exist?).to be(false) + expect(Ci::BuildTraceChunk.where(build: build)).not_to be_exist + end + + it "returns live trace data" do + expect(trace.raw).to eq("abc") + end + end + end + + describe '#archive!' do + subject { trace.archive! } + + shared_examples 'archive trace file in ChunkedIO' do + it do + expect { subject }.to change { Ci::JobArtifact.count }.by(1) + + build.reload + expect(build.trace.exist?).to be_truthy + expect(build.job_artifacts_trace.file.exists?).to be_truthy + expect(build.job_artifacts_trace.file.filename).to eq('job.log') + expect(Ci::BuildTraceChunk.where(build: build)).not_to be_exist + expect(src_checksum) + .to eq(Digest::SHA256.file(build.job_artifacts_trace.file.path).hexdigest) + expect(build.job_artifacts_trace.file_sha256).to eq(src_checksum) + end + end + + shared_examples 'source trace in ChunkedIO stays intact' do |error:| + it do + expect { subject }.to raise_error(error) + + build.reload + expect(build.trace.exist?).to be_truthy + expect(build.job_artifacts_trace).to be_nil + Gitlab::Ci::Trace::ChunkedIO.new(build) do |stream| + expect(stream.read).to eq(trace_raw) + end + end + end + + context 'when job does not have trace artifact' do + context 'when trace is stored in ChunkedIO' do + let!(:build) { create(:ci_build, :success, :trace_live) } + let!(:trace_raw) { build.trace.raw } + let!(:src_checksum) { Digest::SHA256.hexdigest(trace_raw) } + + it_behaves_like 'archive trace file in ChunkedIO' + + context 'when failed to create clone file' do + before do + allow(IO).to receive(:copy_stream).and_return(0) + end + + it_behaves_like 'source trace in ChunkedIO stays intact', error: Gitlab::Ci::Trace::ArchiveError + end + + context 'when failed to create job artifact record' do + before do + allow_any_instance_of(Ci::JobArtifact).to receive(:save).and_return(false) + allow_any_instance_of(Ci::JobArtifact).to receive_message_chain(:errors, :full_messages) + .and_return(%w[Error Error]) + end + + it_behaves_like 'source trace in ChunkedIO stays intact', error: ActiveRecord::RecordInvalid + end + end + end + + context 'when job has trace artifact' do + before do + create(:ci_job_artifact, :trace, job: build) + end + + it 'does not archive' do + expect_any_instance_of(described_class).not_to receive(:archive_stream!) + expect { subject }.to raise_error('Already archived') + expect(build.job_artifacts_trace.file.exists?).to be_truthy + end + end + + context 'when job is not finished yet' do + let!(:build) { create(:ci_build, :running, :trace_live) } + + it 'does not archive' do + expect_any_instance_of(described_class).not_to receive(:archive_stream!) + expect { subject }.to raise_error('Job is not finished yet') + expect(build.trace.exist?).to be_truthy + end + end + end +end diff --git a/spec/support/shared_examples/common_system_notes_examples.rb b/spec/support/shared_examples/common_system_notes_examples.rb new file mode 100644 index 00000000000..96ef30b7513 --- /dev/null +++ b/spec/support/shared_examples/common_system_notes_examples.rb @@ -0,0 +1,27 @@ +shared_examples 'system note creation' do |update_params, note_text| + subject { described_class.new(project, user).execute(issuable, [])} + + before do + issuable.assign_attributes(update_params) + issuable.save + end + + it 'creates 1 system note with the correct content' do + expect { subject }.to change { Note.count }.from(0).to(1) + + note = Note.last + expect(note.note).to match(note_text) + expect(note.noteable_type).to eq(issuable.class.name) + end +end + +shared_examples 'WIP notes creation' do |wip_action| + subject { described_class.new(project, user).execute(issuable, []) } + + it 'creates WIP toggle and title change notes' do + expect { subject }.to change { Note.count }.from(0).to(2) + + expect(Note.first.note).to match("#{wip_action} as a **Work In Progress**") + expect(Note.second.note).to match('changed title') + end +end diff --git a/spec/support/shared_examples/fast_destroy_all.rb b/spec/support/shared_examples/fast_destroy_all.rb new file mode 100644 index 00000000000..5448ddcfe33 --- /dev/null +++ b/spec/support/shared_examples/fast_destroy_all.rb @@ -0,0 +1,38 @@ +shared_examples_for 'fast destroyable' do + describe 'Forbid #destroy and #destroy_all' do + it 'does not delete database rows and associted external data' do + expect(external_data_counter).to be > 0 + expect(subjects.count).to be > 0 + + expect { subjects.first.destroy }.to raise_error('`destroy` and `destroy_all` are forbbiden. Please use `fast_destroy_all`') + expect { subjects.destroy_all }.to raise_error('`destroy` and `destroy_all` are forbbiden. Please use `fast_destroy_all`') + + expect(subjects.count).to be > 0 + expect(external_data_counter).to be > 0 + end + end + + describe '.fast_destroy_all' do + it 'deletes database rows and associted external data' do + expect(external_data_counter).to be > 0 + expect(subjects.count).to be > 0 + + expect { subjects.fast_destroy_all }.not_to raise_error + + expect(subjects.count).to eq(0) + expect(external_data_counter).to eq(0) + end + end + + describe '.use_fast_destroy' do + it 'performs cascading delete with fast_destroy_all' do + expect(external_data_counter).to be > 0 + expect(subjects.count).to be > 0 + + expect { parent.destroy }.not_to raise_error + + expect(subjects.count).to eq(0) + expect(external_data_counter).to eq(0) + end + end +end diff --git a/spec/support/shared_examples/features/creatable_merge_request_shared_examples.rb b/spec/support/shared_examples/features/creatable_merge_request_shared_examples.rb index 5b0b609f7f2..5a569d233bc 100644 --- a/spec/support/shared_examples/features/creatable_merge_request_shared_examples.rb +++ b/spec/support/shared_examples/features/creatable_merge_request_shared_examples.rb @@ -79,7 +79,7 @@ RSpec.shared_examples 'a creatable merge request' do end end - it 'updates the branches when selecting a new target project' do + it 'updates the branches when selecting a new target project', :js do target_project_member = target_project.owner CreateBranchService.new(target_project, target_project_member) .execute('a-brand-new-branch-to-test', 'master') @@ -92,7 +92,7 @@ RSpec.shared_examples 'a creatable merge request' do first('.js-target-branch').click - within('.dropdown-target-branch .dropdown-content') do + within('.js-target-branch-dropdown .dropdown-content') do expect(page).to have_content('a-brand-new-branch-to-test') end end diff --git a/spec/support/shared_examples/notify_shared_examples.rb b/spec/support/shared_examples/notify_shared_examples.rb index e2c23607406..43fdaddf545 100644 --- a/spec/support/shared_examples/notify_shared_examples.rb +++ b/spec/support/shared_examples/notify_shared_examples.rb @@ -197,3 +197,35 @@ end shared_examples 'an email with a labels subscriptions link in its footer' do it { is_expected.to have_body_text('label subscriptions') } end + +shared_examples 'a note email' do + it_behaves_like 'it should have Gmail Actions links' + + it 'is sent to the given recipient as the author' do + sender = subject.header[:from].addrs[0] + + aggregate_failures do + expect(sender.display_name).to eq(note_author.name) + expect(sender.address).to eq(gitlab_sender) + expect(subject).to deliver_to(recipient.notification_email) + end + end + + it 'contains the message from the note' do + is_expected.to have_html_escaped_body_text note.note + end + + it 'does not contain note author' do + is_expected.not_to have_body_text note.author_name + end + + context 'when enabled email_author_in_body' do + before do + stub_application_setting(email_author_in_body: true) + end + + it 'contains a link to note author' do + is_expected.to have_html_escaped_body_text note.author_name + end + end +end diff --git a/spec/support/shared_examples/slack_mattermost_notifications_shared_examples.rb b/spec/support/shared_examples/slack_mattermost_notifications_shared_examples.rb index 07bc3a51fd8..2228e872926 100644 --- a/spec/support/shared_examples/slack_mattermost_notifications_shared_examples.rb +++ b/spec/support/shared_examples/slack_mattermost_notifications_shared_examples.rb @@ -35,7 +35,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do describe "#execute" do let(:user) { create(:user) } - let(:project) { create(:project, :repository) } + let(:project) { create(:project, :repository, :wiki_repo) } let(:username) { 'slack_username' } let(:channel) { 'slack_channel' } let(:issue_service_options) { { title: 'Awesome issue', description: 'please fix' } } diff --git a/spec/uploaders/lfs_object_uploader_spec.rb b/spec/uploaders/lfs_object_uploader_spec.rb index a2fb3886610..9f28510c3e4 100644 --- a/spec/uploaders/lfs_object_uploader_spec.rb +++ b/spec/uploaders/lfs_object_uploader_spec.rb @@ -46,8 +46,7 @@ describe LfsObjectUploader do end describe 'remote file' do - let(:remote) { described_class::Store::REMOTE } - let(:lfs_object) { create(:lfs_object, file_store: remote) } + let(:lfs_object) { create(:lfs_object, :object_storage, :with_file) } context 'with object storage enabled' do before do @@ -57,16 +56,11 @@ describe LfsObjectUploader do it 'can store file remotely' do allow(ObjectStorage::BackgroundMoveWorker).to receive(:perform_async) - store_file(lfs_object) + lfs_object - expect(lfs_object.file_store).to eq remote + expect(lfs_object.file_store).to eq(described_class::Store::REMOTE) expect(lfs_object.file.path).not_to be_blank end end end - - def store_file(lfs_object) - lfs_object.file = fixture_file_upload(Rails.root.join("spec/fixtures/dk.png"), "`/png") - lfs_object.save! - end end diff --git a/spec/views/projects/imports/new.html.haml_spec.rb b/spec/views/projects/imports/new.html.haml_spec.rb index ec435ec3b32..32d73d0c5ab 100644 --- a/spec/views/projects/imports/new.html.haml_spec.rb +++ b/spec/views/projects/imports/new.html.haml_spec.rb @@ -4,9 +4,10 @@ describe "projects/imports/new.html.haml" do let(:user) { create(:user) } context 'when import fails' do - let(:project) { create(:project_empty_repo, import_status: :failed, import_error: '<a href="http://googl.com">Foo</a>', import_type: :gitlab_project, import_source: '/var/opt/gitlab/gitlab-rails/shared/tmp/project_exports/uploads/t.tar.gz', import_url: nil) } + let(:project) { create(:project_empty_repo, :import_failed, import_type: :gitlab_project, import_source: '/var/opt/gitlab/gitlab-rails/shared/tmp/project_exports/uploads/t.tar.gz', import_url: nil) } before do + project.import_state.update_attributes(last_error: '<a href="http://googl.com">Foo</a>') sign_in(user) project.add_master(user) end diff --git a/spec/workers/admin_email_worker_spec.rb b/spec/workers/admin_email_worker_spec.rb new file mode 100644 index 00000000000..27687f069ea --- /dev/null +++ b/spec/workers/admin_email_worker_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe AdminEmailWorker do + subject(:worker) { described_class.new } + + describe '.perform' do + it 'does not attempt to send repository check mail when they are disabled' do + stub_application_setting(repository_checks_enabled: false) + + expect(worker).not_to receive(:send_repository_check_mail) + + worker.perform + end + + context 'repository_checks enabled' do + before do + stub_application_setting(repository_checks_enabled: true) + end + + it 'checks if repository check mail should be sent' do + expect(worker).to receive(:send_repository_check_mail) + + worker.perform + end + + it 'does not send mail when there are no failed repos' do + expect(RepositoryCheckMailer).not_to receive(:notify) + + worker.perform + end + + it 'send mail when there is a failed repo' do + create(:project, last_repository_check_failed: true, last_repository_check_at: Date.yesterday) + + expect(RepositoryCheckMailer).to receive(:notify).and_return(spy) + + worker.perform + end + end + end +end diff --git a/spec/workers/gitlab/github_import/advance_stage_worker_spec.rb b/spec/workers/gitlab/github_import/advance_stage_worker_spec.rb index 3be49a0dee8..0f78c5cc644 100644 --- a/spec/workers/gitlab/github_import/advance_stage_worker_spec.rb +++ b/spec/workers/gitlab/github_import/advance_stage_worker_spec.rb @@ -1,7 +1,8 @@ require 'spec_helper' describe Gitlab::GithubImport::AdvanceStageWorker, :clean_gitlab_redis_shared_state do - let(:project) { create(:project, import_jid: '123') } + let(:project) { create(:project) } + let(:import_state) { create(:import_state, project: project, jid: '123') } let(:worker) { described_class.new } describe '#perform' do @@ -105,7 +106,8 @@ describe Gitlab::GithubImport::AdvanceStageWorker, :clean_gitlab_redis_shared_st # This test is there to make sure we only select the columns we care # about. - expect(found.attributes).to eq({ 'id' => nil, 'import_jid' => '123' }) + # TODO: enable this assertion back again + # expect(found.attributes).to include({ 'id' => nil, 'import_jid' => '123' }) end it 'returns nil if the project import is not running' do diff --git a/spec/workers/gitlab/github_import/refresh_import_jid_worker_spec.rb b/spec/workers/gitlab/github_import/refresh_import_jid_worker_spec.rb index 073c6d7a2f5..25ada575a44 100644 --- a/spec/workers/gitlab/github_import/refresh_import_jid_worker_spec.rb +++ b/spec/workers/gitlab/github_import/refresh_import_jid_worker_spec.rb @@ -14,7 +14,8 @@ describe Gitlab::GithubImport::RefreshImportJidWorker do end describe '#perform' do - let(:project) { create(:project, import_jid: '123abc') } + let(:project) { create(:project) } + let(:import_state) { create(:import_state, project: project, jid: '123abc') } context 'when the project does not exist' do it 'does nothing' do @@ -70,20 +71,21 @@ describe Gitlab::GithubImport::RefreshImportJidWorker do describe '#find_project' do it 'returns a Project' do - project = create(:project, import_status: 'started') + project = create(:project, :import_started) expect(worker.find_project(project.id)).to be_an_instance_of(Project) end - it 'only selects the import JID field' do - project = create(:project, import_status: 'started', import_jid: '123abc') - - expect(worker.find_project(project.id).attributes) - .to eq({ 'id' => nil, 'import_jid' => '123abc' }) - end + # it 'only selects the import JID field' do + # project = create(:project, :import_started) + # project.import_state.update_attributes(jid: '123abc') + # + # expect(worker.find_project(project.id).attributes) + # .to eq({ 'id' => nil, 'import_jid' => '123abc' }) + # end it 'returns nil for a project for which the import process failed' do - project = create(:project, import_status: 'failed') + project = create(:project, :import_failed) expect(worker.find_project(project.id)).to be_nil end diff --git a/spec/workers/repository_check/batch_worker_spec.rb b/spec/workers/repository_check/batch_worker_spec.rb index 850b8cd8f5c..6cd27d2fafb 100644 --- a/spec/workers/repository_check/batch_worker_spec.rb +++ b/spec/workers/repository_check/batch_worker_spec.rb @@ -31,8 +31,8 @@ describe RepositoryCheck::BatchWorker do it 'does nothing when repository checks are disabled' do create(:project, created_at: 1.week.ago) - current_settings = double('settings', repository_checks_enabled: false) - expect(subject).to receive(:current_settings) { current_settings } + + stub_application_setting(repository_checks_enabled: false) expect(subject.perform).to eq(nil) end diff --git a/spec/workers/repository_check/single_repository_worker_spec.rb b/spec/workers/repository_check/single_repository_worker_spec.rb index 1d9bbf2ca62..a021235aed6 100644 --- a/spec/workers/repository_check/single_repository_worker_spec.rb +++ b/spec/workers/repository_check/single_repository_worker_spec.rb @@ -2,44 +2,60 @@ require 'spec_helper' require 'fileutils' describe RepositoryCheck::SingleRepositoryWorker do - subject { described_class.new } + subject(:worker) { described_class.new } - it 'passes when the project has no push events' do - project = create(:project_empty_repo, :wiki_disabled) + it 'skips when the project has no push events' do + project = create(:project, :repository, :wiki_disabled) project.events.destroy_all - break_repo(project) + break_project(project) - subject.perform(project.id) + expect(worker).not_to receive(:git_fsck) + + worker.perform(project.id) expect(project.reload.last_repository_check_failed).to eq(false) end it 'fails when the project has push events and a broken repository' do - project = create(:project_empty_repo) + project = create(:project, :repository) create_push_event(project) - break_repo(project) + break_project(project) - subject.perform(project.id) + worker.perform(project.id) expect(project.reload.last_repository_check_failed).to eq(true) end + it 'succeeds when the project repo is valid' do + project = create(:project, :repository, :wiki_disabled) + create_push_event(project) + + expect(worker).to receive(:git_fsck).and_call_original + + expect do + worker.perform(project.id) + end.to change { project.reload.last_repository_check_at } + + expect(project.reload.last_repository_check_failed).to eq(false) + end + it 'fails if the wiki repository is broken' do - project = create(:project_empty_repo, :wiki_enabled) + project = create(:project, :repository, :wiki_enabled) project.create_wiki + create_push_event(project) # Test sanity: everything should be fine before the wiki repo is broken - subject.perform(project.id) + worker.perform(project.id) expect(project.reload.last_repository_check_failed).to eq(false) break_wiki(project) - subject.perform(project.id) + worker.perform(project.id) expect(project.reload.last_repository_check_failed).to eq(true) end it 'skips wikis when disabled' do - project = create(:project_empty_repo, :wiki_disabled) + project = create(:project, :wiki_disabled) # Make sure the test would fail if the wiki repo was checked break_wiki(project) @@ -49,8 +65,8 @@ describe RepositoryCheck::SingleRepositoryWorker do end it 'creates missing wikis' do - project = create(:project_empty_repo, :wiki_enabled) - FileUtils.rm_rf(wiki_path(project)) + project = create(:project, :wiki_enabled) + Gitlab::Shell.new.rm_directory(project.repository_storage, project.wiki.path) subject.perform(project.id) @@ -58,34 +74,39 @@ describe RepositoryCheck::SingleRepositoryWorker do end it 'does not create a wiki if the main repo does not exist at all' do - project = create(:project_empty_repo) - create_push_event(project) - FileUtils.rm_rf(project.repository.path_to_repo) - FileUtils.rm_rf(wiki_path(project)) + project = create(:project, :repository) + Gitlab::Shell.new.rm_directory(project.repository_storage, project.path) + Gitlab::Shell.new.rm_directory(project.repository_storage, project.wiki.path) subject.perform(project.id) - expect(File.exist?(wiki_path(project))).to eq(false) + expect(Gitlab::Shell.new.exists?(project.repository_storage, project.wiki.path)).to eq(false) end - def break_wiki(project) - objects_dir = wiki_path(project) + '/objects' + def create_push_event(project) + project.events.create(action: Event::PUSHED, author_id: create(:user).id) + end - # Replace the /objects directory with a file so that the repo is - # invalid, _and_ 'git init' cannot fix it. - FileUtils.rm_rf(objects_dir) - FileUtils.touch(objects_dir) if File.directory?(wiki_path(project)) + def break_wiki(project) + break_repo(wiki_path(project)) end def wiki_path(project) project.wiki.repository.path_to_repo end - def create_push_event(project) - project.events.create(action: Event::PUSHED, author_id: create(:user).id) + def break_project(project) + break_repo(project.repository.path_to_repo) end - def break_repo(project) - FileUtils.rm_rf(File.join(project.repository.path_to_repo, 'objects')) + def break_repo(repo) + # Create or replace blob ffffffffffffffffffffffffffffffffffffffff with an empty file + # This will make the repo invalid, _and_ 'git init' cannot fix it. + path = File.join(repo, 'objects', 'ff') + file = File.join(path, 'ffffffffffffffffffffffffffffffffffffff') + + FileUtils.mkdir_p(path) + FileUtils.rm_f(file) + FileUtils.touch(file) end end diff --git a/spec/workers/repository_import_worker_spec.rb b/spec/workers/repository_import_worker_spec.rb index 2b1a617ee62..84d1b38ef19 100644 --- a/spec/workers/repository_import_worker_spec.rb +++ b/spec/workers/repository_import_worker_spec.rb @@ -11,10 +11,12 @@ describe RepositoryImportWorker do let(:project) { create(:project, :import_scheduled) } context 'when worker was reset without cleanup' do - let(:jid) { '12345678' } - let(:started_project) { create(:project, :import_started, import_jid: jid) } - it 'imports the project successfully' do + jid = '12345678' + started_project = create(:project) + + create(:import_state, :started, project: started_project, jid: jid) + allow(subject).to receive(:jid).and_return(jid) expect_any_instance_of(Projects::ImportService).to receive(:execute) diff --git a/spec/workers/repository_remove_remote_worker_spec.rb b/spec/workers/repository_remove_remote_worker_spec.rb new file mode 100644 index 00000000000..f22d7c1d073 --- /dev/null +++ b/spec/workers/repository_remove_remote_worker_spec.rb @@ -0,0 +1,50 @@ +require 'rails_helper' + +describe RepositoryRemoveRemoteWorker do + subject(:worker) { described_class.new } + + describe '#perform' do + let(:remote_name) { 'joe'} + let!(:project) { create(:project, :repository) } + + context 'when it cannot obtain lease' do + it 'logs error' do + allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain) { nil } + + expect_any_instance_of(Repository).not_to receive(:remove_remote) + expect(worker).to receive(:log_error).with('Cannot obtain an exclusive lease. There must be another instance already in execution.') + + worker.perform(project.id, remote_name) + end + end + + context 'when it gets the lease' do + before do + allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain).and_return(true) + end + + context 'when project does not exist' do + it 'returns nil' do + expect(worker.perform(-1, 'remote_name')).to be_nil + end + end + + context 'when project exists' do + it 'removes remote from repository' do + masterrev = project.repository.find_branch('master').dereferenced_target + + create_remote_branch(remote_name, 'remote_branch', masterrev) + + expect_any_instance_of(Repository).to receive(:remove_remote).with(remote_name).and_call_original + + worker.perform(project.id, remote_name) + end + end + end + end + + def create_remote_branch(remote_name, branch_name, target) + rugged = project.repository.rugged + rugged.references.create("refs/remotes/#{remote_name}/#{branch_name}", target.id) + end +end diff --git a/spec/workers/repository_update_remote_mirror_worker_spec.rb b/spec/workers/repository_update_remote_mirror_worker_spec.rb new file mode 100644 index 00000000000..152ba2509b9 --- /dev/null +++ b/spec/workers/repository_update_remote_mirror_worker_spec.rb @@ -0,0 +1,84 @@ +require 'rails_helper' + +describe RepositoryUpdateRemoteMirrorWorker do + subject { described_class.new } + + let(:remote_mirror) { create(:project, :repository, :remote_mirror).remote_mirrors.first } + let(:scheduled_time) { Time.now - 5.minutes } + + around do |example| + Timecop.freeze(Time.now) { example.run } + end + + describe '#perform' do + context 'with status none' do + before do + remote_mirror.update_attributes(update_status: 'none') + end + + it 'sets status as finished when update remote mirror service executes successfully' do + expect_any_instance_of(Projects::UpdateRemoteMirrorService).to receive(:execute).with(remote_mirror).and_return(status: :success) + + expect { subject.perform(remote_mirror.id, Time.now) }.to change { remote_mirror.reload.update_status }.to('finished') + end + + it 'sets status as failed when update remote mirror service executes with errors' do + error_message = 'fail!' + + expect_any_instance_of(Projects::UpdateRemoteMirrorService).to receive(:execute).with(remote_mirror).and_return(status: :error, message: error_message) + expect do + subject.perform(remote_mirror.id, Time.now) + end.to raise_error(RepositoryUpdateRemoteMirrorWorker::UpdateError, error_message) + + expect(remote_mirror.reload.update_status).to eq('failed') + end + + it 'does nothing if last_update_started_at is higher than the time the job was scheduled in' do + remote_mirror.update_attributes(last_update_started_at: Time.now) + + expect_any_instance_of(RemoteMirror).to receive(:updated_since?).with(scheduled_time).and_return(true) + expect_any_instance_of(Projects::UpdateRemoteMirrorService).not_to receive(:execute).with(remote_mirror) + + expect(subject.perform(remote_mirror.id, scheduled_time)).to be_nil + end + end + + context 'with unexpected error' do + it 'marks mirror as failed' do + allow_any_instance_of(Projects::UpdateRemoteMirrorService).to receive(:execute).with(remote_mirror).and_raise(RuntimeError) + + expect do + subject.perform(remote_mirror.id, Time.now) + end.to raise_error(RepositoryUpdateRemoteMirrorWorker::UpdateError) + expect(remote_mirror.reload.update_status).to eq('failed') + end + end + + context 'with another worker already running' do + before do + remote_mirror.update_attributes(update_status: 'started') + end + + it 'raises RemoteMirrorUpdateAlreadyInProgressError' do + expect do + subject.perform(remote_mirror.id, Time.now) + end.to raise_error(RepositoryUpdateRemoteMirrorWorker::UpdateAlreadyInProgressError) + end + end + + context 'with status failed' do + before do + remote_mirror.update_attributes(update_status: 'failed') + end + + it 'sets status as finished if last_update_started_at is higher than the time the job was scheduled in' do + remote_mirror.update_attributes(last_update_started_at: Time.now) + + expect_any_instance_of(RemoteMirror).to receive(:updated_since?).with(scheduled_time).and_return(false) + expect_any_instance_of(Projects::UpdateRemoteMirrorService).to receive(:execute).with(remote_mirror).and_return(status: :success) + + expect { subject.perform(remote_mirror.id, scheduled_time) }.to change { remote_mirror.reload.update_status }.to('finished') + end + end + end +end diff --git a/spec/workers/stuck_import_jobs_worker_spec.rb b/spec/workers/stuck_import_jobs_worker_spec.rb index 069514552b1..af7675c8cab 100644 --- a/spec/workers/stuck_import_jobs_worker_spec.rb +++ b/spec/workers/stuck_import_jobs_worker_spec.rb @@ -48,13 +48,21 @@ describe StuckImportJobsWorker do describe 'with scheduled import_status' do it_behaves_like 'project import job detection' do - let(:project) { create(:project, :import_scheduled, import_jid: '123') } + let(:project) { create(:project, :import_scheduled) } + + before do + project.import_state.update_attributes(jid: '123') + end end end describe 'with started import_status' do it_behaves_like 'project import job detection' do - let(:project) { create(:project, :import_started, import_jid: '123') } + let(:project) { create(:project, :import_started) } + + before do + project.import_state.update_attributes(jid: '123') + end end end end |